From 9e366e0363f44cfd7f7a4c357b2350260aed7d10 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 5 Aug 2026 11:12:15 +0200 Subject: [PATCH 1/4] docs(governance): plan 0.11.0 adoption Co-authored-by: Koru Agent --- TODO.md | 6 ++- project/TICKETS.md | 11 +++-- project/ticket-006/README.md | 41 +++++++++++++++++ project/ticket-006/ai-codex-logs.txt | 10 ++++ project/ticket-006/ai-codex.md | 39 ++++++++++++++++ project/ticket-006/changelog.md | 8 ++++ project/ticket-006/intent.json | 68 ++++++++++++++++++++++++++++ project/ticket-006/preprompt.md | 13 ++++++ 8 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 project/ticket-006/README.md create mode 100644 project/ticket-006/ai-codex-logs.txt create mode 100644 project/ticket-006/ai-codex.md create mode 100644 project/ticket-006/changelog.md create mode 100644 project/ticket-006/intent.json create mode 100644 project/ticket-006/preprompt.md diff --git a/TODO.md b/TODO.md index 0e023e8..8822aa9 100644 --- a/TODO.md +++ b/TODO.md @@ -12,12 +12,16 @@ `publish-only`, and `pull-request` modes. - [x] Review [ticket-005](project/ticket-005/README.md): enable the governed delivery policy in Goal configuration and install the local pre-push guard. +- [ ] Review [ticket-006](project/ticket-006/README.md): adopt immutable + new-project 0.11.0 through Goal's local governance adapter before adding the + CC-to-kind runtime classifier. State: `PLAN / WAIT_FOR_APPROVAL`. - [ ] After governance bootstrap, execute the sequential phases defined in [the refactoring plan](docs/GOAL_KORU_SUBACTOR_REFACTORING_PLAN.md), with one narrowly scoped ticket active at a time. > Current workflow state: `ticket-001 DONE`; `ticket-002 DONE`; -> `ticket-003 DONE`; `ticket-004 DONE`; `ticket-005 DONE`. +> `ticket-003 DONE`; `ticket-004 DONE`; `ticket-005 DONE`; +> `ticket-006 PLAN / WAIT_FOR_APPROVAL`. > **Recently shipped (manual note):** `goal all [PATHS...]` monorepo sweep — > runs `goal -a` in every git repo with uncommitted changes under the given diff --git a/project/TICKETS.md b/project/TICKETS.md index edb5abe..dd8f984 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -6,9 +6,10 @@ This file indexes governance tickets without taking ownership of | Ticket ID | Spec | Preprompt | Human input | Agent plans | Agent logs | Changelog | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | -| **ticket-001** | [`README.md`](./ticket-001/README.md) | [`preprompt.md`](./ticket-001/preprompt.md) | - | [`ai-codex.md`](./ticket-001/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-001/ai-codex-logs.txt) | [`changelog.md`](./ticket-001/changelog.md) | -| **ticket-002** | [`README.md`](./ticket-002/README.md) | [`preprompt.md`](./ticket-002/preprompt.md) | - | [`ai-codex.md`](./ticket-002/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-002/ai-codex-logs.txt) | [`changelog.md`](./ticket-002/changelog.md) | -| **ticket-003** | [`README.md`](./ticket-003/README.md) | [`preprompt.md`](./ticket-003/preprompt.md) | - | [`ai-codex.md`](./ticket-003/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-003/ai-codex-logs.txt) | [`changelog.md`](./ticket-003/changelog.md) | -| **ticket-004** | [`README.md`](./ticket-004/README.md) | [`preprompt.md`](./ticket-004/preprompt.md) | - | [`ai-codex.md`](./ticket-004/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-004/ai-codex-logs.txt) | [`changelog.md`](./ticket-004/changelog.md) | -| **ticket-005** | [`README.md`](./ticket-005/README.md) | [`preprompt.md`](./ticket-005/preprompt.md) | - | [`ai-codex.md`](./ticket-005/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-005/ai-codex-logs.txt) | [`changelog.md`](./ticket-005/changelog.md) | +| **ticket-001** | [`README.md`](./ticket-001/README.md) | [`preprompt.md`](./ticket-001/preprompt.md) | - | [`ai-codex.md`](./ticket-001/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-001/ai-codex-logs.txt) | [`changelog.md`](./ticket-001/changelog.md) | +| **ticket-002** | [`README.md`](./ticket-002/README.md) | [`preprompt.md`](./ticket-002/preprompt.md) | - | [`ai-codex.md`](./ticket-002/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-002/ai-codex-logs.txt) | [`changelog.md`](./ticket-002/changelog.md) | +| **ticket-003** | [`README.md`](./ticket-003/README.md) | [`preprompt.md`](./ticket-003/preprompt.md) | - | [`ai-codex.md`](./ticket-003/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-003/ai-codex-logs.txt) | [`changelog.md`](./ticket-003/changelog.md) | +| **ticket-004** | [`README.md`](./ticket-004/README.md) | [`preprompt.md`](./ticket-004/preprompt.md) | - | [`ai-codex.md`](./ticket-004/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-004/ai-codex-logs.txt) | [`changelog.md`](./ticket-004/changelog.md) | +| **ticket-005** | [`README.md`](./ticket-005/README.md) | [`preprompt.md`](./ticket-005/preprompt.md) | - | [`ai-codex.md`](./ticket-005/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-005/ai-codex-logs.txt) | [`changelog.md`](./ticket-005/changelog.md) | +| **ticket-006** | [`README.md`](./ticket-006/README.md) | [`preprompt.md`](./ticket-006/preprompt.md) | - | [`ai-codex.md`](./ticket-006/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-006/ai-codex-logs.txt) | [`changelog.md`](./ticket-006/changelog.md) | diff --git a/project/ticket-006/README.md b/project/ticket-006/README.md new file mode 100644 index 0000000..251b556 --- /dev/null +++ b/project/ticket-006/README.md @@ -0,0 +1,41 @@ +# Ticket 006: Adopt immutable new-project 0.11.0 + +- **ID**: ticket-006 +- **Owner**: unresolved:human +- **Status**: PLAN +- **Workflow state**: WAIT_FOR_APPROVAL +- **Created**: 2026-08-05 + +## Goal and scope + +Upgrade Goal's governance package from immutable new-project 0.9.0 to the +published 0.11.0 release SHA +`cc9b04673bbd85cb4e35fb683d288ef34be1485f`. Preserve Goal's repository-specific +workstreams and optional-Docker setting, while installing the canonical work +classification DSL and current approval-evidence contract. + +This ticket changes governance artifacts only. Mapping Goal's measured +complexity deltas into `BUG/regression` and `SERVICE/health` remains a separate +application ticket after adoption. + +## Acceptance criteria + +- [ ] AC-01: Scope and immutable source SHA are approved by a human owner. +- [ ] AC-02: The target manifest preserves Goal ownership and declares 0.11.0. +- [ ] AC-03: Local Goal reports and applies only the reviewed immutable upgrade. +- [ ] AC-04: The resulting lock binds 0.11.0, published status, full SHA and + managed classification DSL/schema files. +- [ ] AC-05: Governance and focused Goal adoption tests pass; Docker remains + optional as declared by the preserved target manifest. + +## Participants + +- Human participant: unresolved; no user-* file was created by this script. +- Agent participant: [ai-codex.md](ai-codex.md) + +## Risk boundary + +- The installed global `goal` executable lacks the governance command; this + ticket uses the repository's verified `.venv/bin/goal` entry point. +- Cross-version adoption fails closed until the target manifest version is + explicitly reviewed and advanced. diff --git a/project/ticket-006/ai-codex-logs.txt b/project/ticket-006/ai-codex-logs.txt new file mode 100644 index 0000000..c832ed1 --- /dev/null +++ b/project/ticket-006/ai-codex-logs.txt @@ -0,0 +1,10 @@ +2026-08-05 plan preflight +global goal governance: unavailable +local .venv/bin/goal governance: available +source revision: cc9b04673bbd85cb4e35fb683d288ef34be1485f +read-only adoption result: target manifest version must equal adopted standard version 0.11.0 +result: expected PLAN boundary; no target files written by adoption command +python3 -m json.tool project/ticket-006/intent.json: PASS +new-project 0.11.0 intent schema validation: PASS +git diff --check: PASS +implementation tests: NOT RUN; ticket remains PLAN / WAIT_FOR_APPROVAL diff --git a/project/ticket-006/ai-codex.md b/project/ticket-006/ai-codex.md new file mode 100644 index 0000000..78fe49a --- /dev/null +++ b/project/ticket-006/ai-codex.md @@ -0,0 +1,39 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-006 +--- +# Participant: codex (AI agent) + +## Understanding + +Goal is pinned to new-project 0.9.0 at +`c0bb63e7fc889934140c96b1625f3ab232122baf`. Version 0.11.0 publishes the +canonical classification files needed by the later CC adapter. The immutable +adoption generator correctly refuses to combine a 0.11.0 package with the +current 0.9.0 target manifest until that declaration is reviewed. + +## Execution plan + +1. After approval, transition ticket-006 to `IN_PROGRESS / EDIT`. +2. Advance only the target manifest standard version, preserving customization. +3. Run the local Goal adapter in check mode, review output and apply `--upgrade`. +4. Verify lock provenance and managed work-classification hashes. +5. Run the governance gate and focused adoption/CLI tests. +6. Publish a ticket-scoped PR for independent current-head validation. + +## Actual changes + +- None; waiting for approval. + +## Blockers + +- Human approval is required before implementation. + +## Preflight evidence + +- Local `.venv/bin/goal governance adopt` is available. +- Global Goal does not expose the governance group and is not used. +- Read-only adoption fails at the expected manifest version boundary without + writing target files. diff --git a/project/ticket-006/changelog.md b/project/ticket-006/changelog.md new file mode 100644 index 0000000..31c02da --- /dev/null +++ b/project/ticket-006/changelog.md @@ -0,0 +1,8 @@ +# Ticket Changelog (ticket-006) + +## [0.1.0] - 2026-08-05 + +- Initial governance scaffold created. +- No human participant identity or content was generated. +- Planned the immutable 0.11.0 governance adoption through Goal's local adapter. +- Kept CC-to-kind classification outside this governance-only ticket. diff --git a/project/ticket-006/intent.json b/project/ticket-006/intent.json new file mode 100644 index 0000000..1e9f153 --- /dev/null +++ b/project/ticket-006/intent.json @@ -0,0 +1,68 @@ +{ + "schema": "new-project.intent/v2", + "ticket": "ticket-006", + "summary": "Adopt immutable new-project 0.11.0", + "workstream": "governance", + "allowedPaths": [ + ".governance/**", + "AGENTS.md", + "project.sh", + "project.bat", + "project/governance-check.sh", + "project/governance-check.bat", + "project/new-ticket.sh", + "project/readme.sh", + "project/ticket-006/**", + "TODO.md", + "project/TICKETS.md" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md", + "goal/**", + "tests/**", + "pyproject.toml", + "goal.yaml", + "Dockerfile*", + "compose*.yml" + ], + "stacks": ["python"], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": "ticket-001", + "delivery": { + "acceptedBaseSha": "b84d40a36d9a2e8e5a0e6e99e10c27263ab1a049", + "targetBranch": "main", + "outcome": "Pin Goal to immutable new-project 0.11.0 and install its canonical work-classification package", + "nonGoals": [ + "No Goal runtime classifier or queue change", + "No dependency, package, release or Docker configuration change", + "No branch-based or moving-tag adoption" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 5, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Use Goal's own immutable adoption adapter while preserving the customized target manifest", + "components": [ + {"name": "goal-governance-manifest", "paths": [".governance/manifest.json"]}, + {"name": "managed-governance-package", "paths": [".governance/**", "AGENTS.md", "project.sh", "project.bat", "project/governance-check.*", "project/new-ticket.sh", "project/readme.sh"]} + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": ["Governance lock advances from 0.9.0 to immutable 0.11.0"], + "ui": {"impact": "none", "states": [], "evidence": []}, + "rollback": "Re-adopt the previous full source revision through a reviewed ticket without moving tags" + }, + "runtimeDependencies": [], + "validation": [ + {"criterion": "AC-03", "commands": [".venv/bin/goal governance adopt --source-revision cc9b04673bbd85cb4e35fb683d288ef34be1485f --target-root . --check"], "evidence": "Deterministic immutable adoption plan"}, + {"criterion": "AC-05", "commands": ["./project/governance-check.sh", ".venv/bin/pytest -q tests/test_governance_cmd.py"], "evidence": "Governance and Goal adapter tests pass"} + ] + } +} diff --git a/project/ticket-006/preprompt.md b/project/ticket-006/preprompt.md new file mode 100644 index 0000000..a1238fd --- /dev/null +++ b/project/ticket-006/preprompt.md @@ -0,0 +1,13 @@ +# Ticket preprompt + +- **Task ID**: ticket-006 +- **Task title**: Adopt immutable new-project 0.11.0 +- **Created**: 2026-08-05T09:07:42Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. + +- Adopt only full release SHA `cc9b04673bbd85cb4e35fb683d288ef34be1485f`. +- Preserve Goal's customized manifest, workstreams and optional-Docker setting. +- Execute the local `.venv/bin/goal governance adopt` adapter. +- Do not change Goal runtime classification in this ticket. From cb4a5555fdf837b48d77c62fb468f975eb77fdc9 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 5 Aug 2026 11:16:10 +0200 Subject: [PATCH 2/4] docs(governance): record Goal baseline CI debt Co-authored-by: Koru Agent --- project/ticket-006/README.md | 3 +++ project/ticket-006/ai-codex-logs.txt | 6 ++++++ project/ticket-006/ai-codex.md | 5 +++++ project/ticket-006/changelog.md | 2 ++ 4 files changed, 16 insertions(+) diff --git a/project/ticket-006/README.md b/project/ticket-006/README.md index 251b556..4e7cd62 100644 --- a/project/ticket-006/README.md +++ b/project/ticket-006/README.md @@ -39,3 +39,6 @@ application ticket after adoption. ticket uses the repository's verified `.venv/bin/goal` entry point. - Cross-version adoption fails closed until the target manifest version is explicitly reviewed and advanced. +- Hosted CI already fails on unchanged `main@b84d40a` because + `_validate_pfix_env` references an undefined `api_key` on Python 3.12/3.13. + This pre-existing `SERVICE/health` bug is outside the governance-only scope. diff --git a/project/ticket-006/ai-codex-logs.txt b/project/ticket-006/ai-codex-logs.txt index c832ed1..d5e2cca 100644 --- a/project/ticket-006/ai-codex-logs.txt +++ b/project/ticket-006/ai-codex-logs.txt @@ -8,3 +8,9 @@ python3 -m json.tool project/ticket-006/intent.json: PASS new-project 0.11.0 intent schema validation: PASS git diff --check: PASS implementation tests: NOT RUN; ticket remains PLAN / WAIT_FOR_APPROVAL +2026-08-05 hosted draft evidence +PR #16 Python 3.12/3.13: FAIL +failure: NameError name 'api_key' is not defined in goal/project_bootstrap.py:_validate_pfix_env +Python 3.13 summary: 1 failed, 471 passed, 7 skipped +unchanged main@b84d40a CI run 30986834239: FAILURE +classification: pre-existing SERVICE/health debt outside ticket-006 scope diff --git a/project/ticket-006/ai-codex.md b/project/ticket-006/ai-codex.md index 78fe49a..48c79be 100644 --- a/project/ticket-006/ai-codex.md +++ b/project/ticket-006/ai-codex.md @@ -30,6 +30,8 @@ current 0.9.0 target manifest until that declaration is reviewed. ## Blockers - Human approval is required before implementation. +- Full hosted CI requires a separate application ticket for the pre-existing + environment-discovery defect; ticket-006 does not broaden into runtime code. ## Preflight evidence @@ -37,3 +39,6 @@ current 0.9.0 target manifest until that declaration is reviewed. - Global Goal does not expose the governance group and is not used. - Read-only adoption fails at the expected manifest version boundary without writing target files. +- Draft PR CI reproduces the current-main failure in + `tests/test_project_bootstrap.py`: `NameError: api_key` from + `goal/project_bootstrap.py`; 471 tests pass and seven skip on Python 3.13. diff --git a/project/ticket-006/changelog.md b/project/ticket-006/changelog.md index 31c02da..58d12b3 100644 --- a/project/ticket-006/changelog.md +++ b/project/ticket-006/changelog.md @@ -6,3 +6,5 @@ - No human participant identity or content was generated. - Planned the immutable 0.11.0 governance adoption through Goal's local adapter. - Kept CC-to-kind classification outside this governance-only ticket. +- Recorded the current-main `api_key` NameError as separate SERVICE debt rather + than expanding the adoption ticket into application repair. From c2965264d771a3d0bc8a760a719db7297f07f395 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 5 Aug 2026 11:24:59 +0200 Subject: [PATCH 3/4] chore(ticket-006): adopt governance 0.11.0 Co-authored-by: Koru Agent --- .governance/approval-evidence.schema.json | 89 + .governance/diagnostics.json | 8 + .governance/governance_check.py | 1938 +++++++++++++++---- .governance/intent.schema.json | 138 +- .governance/manifest.json | 2 +- .governance/manifest.lock.json | 20 +- .governance/manifest.schema.json | 78 +- .governance/package-manifest.json | 23 + .governance/work-classification.dsl.json | 120 ++ .governance/work-classification.schema.json | 180 ++ AGENTS.md | 15 +- TODO.md | 4 +- project/ticket-006/README.md | 28 +- project/ticket-006/ai-codex-logs.txt | 9 + project/ticket-006/ai-codex.md | 10 +- project/ticket-006/changelog.md | 3 + 16 files changed, 2215 insertions(+), 450 deletions(-) create mode 100644 .governance/approval-evidence.schema.json create mode 100644 .governance/package-manifest.json create mode 100644 .governance/work-classification.dsl.json create mode 100644 .governance/work-classification.schema.json diff --git a/.governance/approval-evidence.schema.json b/.governance/approval-evidence.schema.json new file mode 100644 index 0000000..b22e8d0 --- /dev/null +++ b/.governance/approval-evidence.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/approval-evidence.schema.json", + "title": "new-project trusted merge approval evidence", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "source", + "repository", + "pullRequest", + "headSha", + "ticket", + "actor", + "verification" + ], + "properties": { + "schema": { "const": "new-project.approval-evidence/v1" }, + "source": { + "enum": ["github-review", "github-app-review", "signed-attestation"] + }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "pullRequest": { "type": "integer", "minimum": 1 }, + "headSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "ticket": { "type": "string", "pattern": "^ticket-[0-9]{3}$" }, + "actor": { + "type": "object", + "additionalProperties": false, + "required": ["login", "type"], + "properties": { + "login": { "type": "string", "minLength": 1 }, + "type": { "enum": ["User", "Bot", "Workflow"] } + } + }, + "verification": { + "type": "object", + "additionalProperties": false, + "required": ["method", "verified"], + "properties": { + "method": { + "enum": ["github-api-allowlist", "github-attestation", "sigstore"] + }, + "verified": { "const": true }, + "issuer": { "type": "string", "minLength": 1 }, + "predicateType": { "type": "string", "minLength": 1 } + } + } + }, + "allOf": [ + { + "if": { "properties": { "source": { "const": "github-review" } } }, + "then": { + "properties": { + "actor": { "properties": { "type": { "const": "User" } } }, + "verification": { + "properties": { "method": { "const": "github-api-allowlist" } } + } + } + } + }, + { + "if": { "properties": { "source": { "const": "github-app-review" } } }, + "then": { + "properties": { + "actor": { "properties": { "type": { "const": "Bot" } } }, + "verification": { + "properties": { "method": { "const": "github-api-allowlist" } } + } + } + } + }, + { + "if": { "properties": { "source": { "const": "signed-attestation" } } }, + "then": { + "properties": { + "verification": { + "required": ["method", "verified", "issuer", "predicateType"], + "properties": { + "method": { "enum": ["github-attestation", "sigstore"] } + } + } + } + } + } + ] +} diff --git a/.governance/diagnostics.json b/.governance/diagnostics.json index 941462c..c412b3f 100644 --- a/.governance/diagnostics.json +++ b/.governance/diagnostics.json @@ -16,6 +16,9 @@ "GOV-INTENT-003": "Ticket intent was not committed before the first implementation commit.", "GOV-APPROVAL-001": "Implementation lacks approval from a trusted external source.", "GOV-APPROVAL-002": "Approval refers to a different ticket.", + "GOV-APPROVAL-003": "Approval evidence is missing, repository-controlled or structurally invalid.", + "GOV-APPROVAL-004": "Approval evidence is bound to another repository, pull request or commit.", + "GOV-APPROVAL-005": "Approval actor or verification method is not trusted for the claimed source.", "GOV-SCOPE-001": "A changed implementation path is outside the approved intent scope.", "GOV-WORKSTREAM-001": "An active v2 ticket declares a missing or unknown workstream.", "GOV-WORKSTREAM-002": "A workstream exceeds its active-ticket limit.", @@ -25,6 +28,11 @@ "GOV-DEPENDENCY-002": "An active ticket depends on a ticket that is missing or not closed.", "GOV-CONFLICT-001": "Tickets declared as conflicting are active at the same time.", "GOV-INTEGRATION-001": "A shared contract path lacks valid routing through an integration ticket.", + "GOV-DELIVERY-001": "The implementation slice lacks or exceeds its approved <=30-minute delivery contract.", + "GOV-DELIVERY-002": "The implementation slice reached its pre-stop checkpoint.", + "GOV-ARCHITECTURE-001": "Architecture ownership, UI/data impact or component mapping is unresolved.", + "GOV-BASE-001": "The target branch or base SHA differs from the approved delivery contract.", + "GOV-BUDGET-001": "The actual implementation diff exceeds its approved file, component, interface or dependency budget.", "GOV-OWNER-001": "An untrusted actor changed a human-owned participant file.", "GOV-DOCKER-001": "The required Docker runtime declaration is incomplete.", "GOV-STACK-001": "The declared technology stack lacks its required project marker.", diff --git a/.governance/governance_check.py b/.governance/governance_check.py index 0a89c73..574cdad 100644 --- a/.governance/governance_check.py +++ b/.governance/governance_check.py @@ -9,13 +9,14 @@ import json import os import re +import stat import subprocess import sys from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Iterable -RUNTIME_VERSION = "0.9.0" +RUNTIME_VERSION = "0.10.0" ACTIVE_DEFAULT = {"IN_PROGRESS"} EXECUTABLE_SUFFIXES = { ".bat", ".c", ".cc", ".cmd", ".cpp", ".go", ".java", ".js", ".jsx", @@ -127,6 +128,215 @@ def relative_pattern(value: str) -> bool: ) +def approval_evidence_config_valid(value: Any) -> bool: + if value is None: + return True + return ( + isinstance(value, dict) + and set(value) == { + "schema", "requiredBindings", "reviewVerificationMethod", + "signedAttestationPredicateType", + } + and value.get("schema") == "new-project.approval-evidence/v1" + and value.get("requiredBindings") == [ + "repository", "pullRequest", "headSha", "ticket", "actor", + ] + and value.get("reviewVerificationMethod") == "github-api-allowlist" + and value.get("signedAttestationPredicateType") + == "https://wellmanifest.dev/attestations/validator/v1" + ) + + +def branch_name(value: Any) -> bool: + return ( + isinstance(value, str) + and bool(value) + and not value.startswith("/") + and re.search(r"(?:\.\.|//|@\{|[~^:?*\[\\])", value) is None + ) + + +def integer_fields_valid(value: dict[str, Any], fields: Iterable[str]) -> bool: + return all( + isinstance(value.get(name), int) and not isinstance(value[name], bool) + for name in fields + ) + + +def relative_pattern_list(value: Any, *, nonempty: bool = False) -> bool: + return string_list(value, nonempty=nonempty) and all(relative_pattern(item) for item in value) + + +def delivery_limits_valid(value: dict[str, Any]) -> bool: + return all([ + isinstance(value.get("requiredForImplementation"), bool), + 1 <= value["maxActiveMinutes"] <= 30, + 1 <= value["checkpointMinutes"] < value["maxActiveMinutes"], + value["maxImplementationFiles"] >= 1, + value["maxAffectedComponents"] >= 1, + value["maxPublicInterfaceChanges"] >= 0, + value["maxRuntimeDependencies"] >= 0, + ]) + + +def delivery_policy_valid(value: Any) -> bool: + fields = { + "requiredForImplementation", "maxActiveMinutes", "checkpointMinutes", + "allowedComplexityClasses", "maxImplementationFiles", + "maxAffectedComponents", "maxPublicInterfaceChanges", + "maxRuntimeDependencies", "targetBranches", "publicInterfacePaths", + "dependencyManifestPaths", + } + if not isinstance(value, dict) or set(value) != fields: + return False + integer_limits = ( + "maxActiveMinutes", "checkpointMinutes", "maxImplementationFiles", + "maxAffectedComponents", "maxPublicInterfaceChanges", + "maxRuntimeDependencies", + ) + if not integer_fields_valid(value, integer_limits): + return False + limits_valid = delivery_limits_valid(value) + classes_valid = ( + string_list(value.get("allowedComplexityClasses"), nonempty=True) + and set(value["allowedComplexityClasses"]) <= {"XS", "S"} + ) + targets_valid = ( + string_list(value.get("targetBranches"), nonempty=True) + and all(branch_name(item) for item in value["targetBranches"]) + ) + paths_valid = relative_pattern_list(value.get("publicInterfacePaths")) and relative_pattern_list( + value.get("dependencyManifestPaths") + ) + return limits_valid and classes_valid and targets_valid and paths_valid + + +def delivery_header_error(value: dict[str, Any]) -> str | None: + if not isinstance(value.get("acceptedBaseSha"), str) or re.fullmatch(r"[0-9a-f]{40}", value["acceptedBaseSha"]) is None: + return "delivery acceptedBaseSha must be a full lowercase commit SHA" + if not branch_name(value.get("targetBranch")): + return "delivery targetBranch is invalid" + if not isinstance(value.get("outcome"), str) or not value["outcome"].strip(): + return "delivery outcome is blank" + if not string_list(value.get("nonGoals"), nonempty=True): + return "delivery nonGoals must be an explicit non-empty list" + if value.get("complexity") not in {"XS", "S"}: + return "delivery complexity must be XS or S" + minutes = value.get("estimatedMinutes") + if not isinstance(minutes, int) or isinstance(minutes, bool) or not 1 <= minutes <= 30: + return "delivery estimatedMinutes must be between 1 and 30" + return None + + +def delivery_budgets_error(budgets: Any) -> str | None: + fields = { + "maxImplementationFiles", "maxAffectedComponents", + "maxPublicInterfaceChanges", "maxRuntimeDependencies", + } + if not isinstance(budgets, dict) or set(budgets) != fields: + return "delivery budgets are incomplete" + if not integer_fields_valid(budgets, fields): + return "delivery budgets must be integers" + if budgets["maxImplementationFiles"] < 1 or budgets["maxAffectedComponents"] < 1: + return "delivery file and component budgets must be positive" + if budgets["maxPublicInterfaceChanges"] < 0 or budgets["maxRuntimeDependencies"] < 0: + return "delivery interface and dependency budgets cannot be negative" + return None + + +def delivery_components_error(components: Any) -> str | None: + if not isinstance(components, list) or not components: + return "delivery architecture requires at least one component" + names: list[str] = [] + for component in components: + if not isinstance(component, dict) or set(component) != {"name", "paths"}: + return "delivery component must contain name and paths" + if not isinstance(component.get("name"), str) or not component["name"].strip(): + return "delivery component name is blank" + if not relative_pattern_list(component.get("paths"), nonempty=True): + return "delivery component paths must be repository-relative patterns" + names.append(component["name"]) + return "delivery component names must be unique" if len(names) != len(set(names)) else None + + +def delivery_ui_error(ui: Any) -> str | None: + if not isinstance(ui, dict) or set(ui) != {"impact", "states", "evidence"}: + return "delivery UI decision is incomplete" + if ui.get("impact") not in {"none", "single-state", "multi-state"}: + return "delivery UI impact is invalid" + if not string_list(ui.get("states")) or not set(ui["states"]) <= {"loading", "empty", "error", "success"}: + return "delivery UI states are invalid" + if not string_list(ui.get("evidence")): + return "delivery UI evidence must be a unique string list" + return delivery_ui_impact_error(ui["impact"], ui["states"], ui["evidence"]) + + +def delivery_ui_impact_error(impact: str, states: list[str], evidence: list[str]) -> str | None: + if impact == "none" and (states or evidence): + return "delivery UI states/evidence must be empty when impact is none" + if impact == "single-state" and (len(states) != 1 or not evidence): + return "single-state UI work requires one state and planned evidence" + if impact == "multi-state" and (len(states) < 2 or not evidence): + return "multi-state UI work requires at least two states and planned evidence" + return None + + +def delivery_architecture_error(architecture: Any) -> str | None: + fields = { + "status", "decision", "components", "responsibilityChanges", + "interfaceChanges", "dataChanges", "ui", "rollback", + } + if not isinstance(architecture, dict) or set(architecture) != fields: + return "delivery architecture decision is incomplete" + if architecture.get("status") != "accepted": + return "delivery architecture status must be accepted before implementation" + for name in ("decision", "rollback"): + if not isinstance(architecture.get(name), str) or not architecture[name].strip(): + return f"delivery architecture {name} is blank" + if not isinstance(architecture.get("responsibilityChanges"), bool): + return "delivery responsibilityChanges must be boolean" + for name in ("interfaceChanges", "dataChanges"): + if not string_list(architecture.get(name)): + return f"delivery architecture {name} must be a unique string list" + return delivery_components_error(architecture.get("components")) or delivery_ui_error(architecture.get("ui")) + + +def delivery_validation_error(validation: Any) -> str | None: + if not isinstance(validation, list) or not validation: + return "delivery validation must map at least one acceptance criterion" + criteria: list[str] = [] + for item in validation: + if not isinstance(item, dict) or set(item) != {"criterion", "commands", "evidence"}: + return "delivery validation entry is incomplete" + if not isinstance(item.get("criterion"), str) or re.fullmatch(r"AC-[0-9]+", item["criterion"]) is None: + return "delivery validation criterion is invalid" + if not string_list(item.get("commands"), nonempty=True): + return "delivery validation commands cannot be empty" + if not isinstance(item.get("evidence"), str) or not item["evidence"].strip(): + return "delivery validation evidence is blank" + criteria.append(item["criterion"]) + return "delivery validation criteria must be unique" if len(criteria) != len(set(criteria)) else None + + +def delivery_intent_error(value: Any) -> str | None: + fields = { + "acceptedBaseSha", "targetBranch", "outcome", "nonGoals", + "complexity", "estimatedMinutes", "budgets", "architecture", + "runtimeDependencies", "validation", + } + if not isinstance(value, dict) or set(value) != fields: + return "delivery must contain exactly the bounded-delivery fields" + error = delivery_header_error(value) or delivery_budgets_error(value.get("budgets")) + if error: + return error + error = delivery_architecture_error(value.get("architecture")) + if error: + return error + if not string_list(value.get("runtimeDependencies")): + return "delivery runtimeDependencies must be a unique string list" + return delivery_validation_error(value.get("validation")) + + def matches(path: str, patterns: Iterable[str]) -> bool: path_parts = path.replace("\\", "/").strip("/").split("/") @@ -197,6 +407,9 @@ def patterns_may_overlap(first: str, second: str) -> bool: second_parts = second.replace("\\", "/").strip("/").split("/") memo: dict[tuple[int, int], bool] = {} + def remaining_are_globstars(parts: list[str], index: int) -> bool: + return all(part == "**" for part in parts[index:]) + def visit(first_index: int, second_index: int) -> bool: key = (first_index, second_index) if key in memo: @@ -204,9 +417,9 @@ def visit(first_index: int, second_index: int) -> bool: if first_index == len(first_parts) and second_index == len(second_parts): result = True elif first_index == len(first_parts): - result = all(part == "**" for part in second_parts[second_index:]) + result = remaining_are_globstars(second_parts, second_index) elif second_index == len(second_parts): - result = all(part == "**" for part in first_parts[first_index:]) + result = remaining_are_globstars(first_parts, first_index) elif first_parts[first_index] == "**" and second_parts[second_index] == "**": result = visit(first_index + 1, second_index) or visit(first_index, second_index + 1) elif first_parts[first_index] == "**": @@ -223,6 +436,26 @@ def visit(first_index: int, second_index: int) -> bool: return visit(0, 0) +def segment_pattern_covered_by(pattern: str, owner_pattern: str) -> bool: + if pattern == owner_pattern: + return True + if not any(char in pattern for char in "*?["): + return fnmatch.fnmatchcase(pattern, owner_pattern) + if owner_pattern == "*": + return True + if "?" in owner_pattern or "[" in owner_pattern or owner_pattern.count("*") != 1: + return False + owner_prefix, owner_suffix = owner_pattern.split("*", 1) + first_magic = min( + (pattern.find(char) for char in "*?[" if char in pattern), + default=len(pattern), + ) + last_magic = max(pattern.rfind(char) for char in "*?[") + pattern_prefix = pattern[:first_magic] + pattern_suffix = pattern[last_magic + 1:] + return pattern_prefix.startswith(owner_prefix) and pattern_suffix.endswith(owner_suffix) + + def pattern_covered_by(pattern: str, owner_pattern: str) -> bool: if pattern == owner_pattern: return True @@ -233,12 +466,14 @@ def pattern_covered_by(pattern: str, owner_pattern: str) -> bool: if owner_parts and owner_parts[-1] == "**" and len(pattern_parts) >= len(owner_parts) - 1: prefix = owner_parts[:-1] return all( - allowed == owned or ( - not any(char in allowed for char in "*?[") - and fnmatch.fnmatchcase(allowed, owned) - ) + segment_pattern_covered_by(allowed, owned) for allowed, owned in zip(pattern_parts, prefix) ) + if len(pattern_parts) == len(owner_parts) and "**" not in owner_parts: + return all( + segment_pattern_covered_by(allowed, owned) + for allowed, owned in zip(pattern_parts, owner_parts) + ) return False @@ -322,100 +557,217 @@ def check_history_order( ) -def basic_manifest_valid(manifest: Any) -> bool: - if not isinstance(manifest, dict) or manifest.get("schema") not in { - "new-project.governance/v1", "new-project.governance/v2", - }: - return False - standard = manifest.get("standard") - ticket = manifest.get("ticket") - docker = manifest.get("docker") - expected_ticket_fields = { +def standard_policy_valid(standard: Any) -> bool: + return ( + isinstance(standard, dict) + and set(standard) == {"id", "version"} + and standard.get("id") == "wellmanifest/new-project" + and isinstance(standard.get("version"), str) + and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", standard["version"]) is not None + ) + + +def ticket_policy_valid(ticket: Any) -> bool: + base_fields = { "root", "directoryPattern", "requiredFiles", "requiredAgentFiles", "activeStatuses", "closedStatuses", "implementationStates", "intentFile", } - if manifest.get("schema") == "new-project.governance/v2": - expected_ticket_fields.add("nonActiveStatuses") + if not isinstance(ticket, dict) or set(ticket) not in { + frozenset(base_fields), frozenset({*base_fields, "nonActiveStatuses"}), + }: + return False + values_valid = ticket_scalar_policy_valid(ticket) and ticket_list_policy_valid(ticket) + if not values_valid: + return False + try: + re.compile(ticket["directoryPattern"]) + except re.error: + return False + return True + + +def ticket_scalar_policy_valid(ticket: dict[str, Any]) -> bool: + return all([ + isinstance(ticket.get("root"), str) and bool(ticket["root"]) and relative_pattern(ticket["root"]), + isinstance(ticket.get("directoryPattern"), str) and bool(ticket["directoryPattern"]), + isinstance(ticket.get("intentFile"), str) and bool(ticket["intentFile"]) and relative_pattern(ticket["intentFile"]), + ]) + + +def ticket_list_policy_valid(ticket: dict[str, Any]) -> bool: status_groups = [ - set(ticket.get(name, [])) if isinstance(ticket, dict) else set() + set(ticket.get(name, [])) for name in ("activeStatuses", "nonActiveStatuses", "closedStatuses") ] - common_valid = ( - isinstance(standard, dict) - and set(standard) == {"id", "version"} - and standard.get("id") == "wellmanifest/new-project" - and isinstance(standard.get("version"), str) - and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", standard["version"]) is not None - and string_list(manifest.get("requiredFiles")) - and string_list(manifest.get("governancePaths")) - and all(relative_pattern(item) for item in manifest["requiredFiles"]) - and all(relative_pattern(item) for item in manifest["governancePaths"]) - and string_list(manifest.get("trustedApprovalSources"), nonempty=True) - and set(manifest["trustedApprovalSources"]) <= {"github-review", "signed-attestation"} - and isinstance(ticket, dict) - and set(ticket) == expected_ticket_fields - and isinstance(ticket.get("root"), str) and bool(ticket["root"]) and relative_pattern(ticket["root"]) - and isinstance(ticket.get("directoryPattern"), str) and bool(ticket["directoryPattern"]) - and string_list(ticket.get("requiredFiles")) - and string_list(ticket.get("requiredAgentFiles")) - and all(relative_pattern(item) for item in [*ticket["requiredFiles"], *ticket["requiredAgentFiles"]]) - and string_list(ticket.get("activeStatuses"), nonempty=True) - and (manifest.get("schema") != "new-project.governance/v2" or string_list(ticket.get("nonActiveStatuses"), nonempty=True)) - and string_list(ticket.get("closedStatuses"), nonempty=True) - and all(left.isdisjoint(right) for index, left in enumerate(status_groups) for right in status_groups[index + 1:]) - and string_list(ticket.get("implementationStates"), nonempty=True) - and isinstance(ticket.get("intentFile"), str) and bool(ticket["intentFile"]) and relative_pattern(ticket["intentFile"]) - and isinstance(docker, dict) + return all([ + relative_pattern_list(ticket.get("requiredFiles")), + relative_pattern_list(ticket.get("requiredAgentFiles")), + string_list(ticket.get("activeStatuses"), nonempty=True), + "nonActiveStatuses" not in ticket or string_list(ticket.get("nonActiveStatuses"), nonempty=True), + string_list(ticket.get("closedStatuses"), nonempty=True), + string_list(ticket.get("implementationStates"), nonempty=True), + all( + left.isdisjoint(right) + for index, left in enumerate(status_groups) + for right in status_groups[index + 1:] + ), + ]) + + +def docker_policy_valid(docker: Any) -> bool: + return ( + isinstance(docker, dict) and set(docker) == {"required", "dockerfiles", "composeFiles"} and isinstance(docker.get("required"), bool) - and string_list(docker.get("dockerfiles"), nonempty=True) - and string_list(docker.get("composeFiles"), nonempty=True) - and all(relative_pattern(item) for item in [*docker["dockerfiles"], *docker["composeFiles"]]) + and relative_pattern_list(docker.get("dockerfiles"), nonempty=True) + and relative_pattern_list(docker.get("composeFiles"), nonempty=True) ) - if common_valid: - try: - re.compile(ticket["directoryPattern"]) - except re.error: - common_valid = False - if not common_valid or manifest.get("schema") == "new-project.governance/v1": + + +def workstreams_policy_valid(workstreams: Any) -> bool: + if not isinstance(workstreams, dict) or not workstreams: + return False + valid_name = re.compile(r"[a-z0-9][a-z0-9-]*").fullmatch + return all( + isinstance(name, str) + and valid_name(name) is not None + and isinstance(item, dict) + and set(item) == {"ownedPaths"} + and relative_pattern_list(item.get("ownedPaths"), nonempty=True) + for name, item in workstreams.items() + ) + + +def integration_policy_valid(integration: Any, workstreams: dict[str, Any]) -> bool: + return ( + isinstance(integration, dict) + and set(integration) == {"workstream", "requiredForPaths"} + and isinstance(integration.get("workstream"), str) + and relative_pattern_list(integration.get("requiredForPaths")) + and integration["workstream"] in workstreams + ) + + +def coordination_policy_valid(coordination: Any) -> bool: + fields = { + "mode", "maxActiveTicketsPerWorkstream", "rejectActiveScopeOverlap", + "workstreams", "integration", + } + if not isinstance(coordination, dict) or set(coordination) != fields: + return False + limit = coordination.get("maxActiveTicketsPerWorkstream") + settings_valid = ( + coordination.get("mode") == "workstreams" + and isinstance(limit, int) + and not isinstance(limit, bool) + and limit >= 1 + and isinstance(coordination.get("rejectActiveScopeOverlap"), bool) + ) + workstreams = coordination.get("workstreams") + return ( + settings_valid + and workstreams_policy_valid(workstreams) + and integration_policy_valid(coordination.get("integration"), workstreams) + ) + + +def common_manifest_policy_valid(manifest: dict[str, Any]) -> bool: + approvals = manifest.get("trustedApprovalSources") + return ( + standard_policy_valid(manifest.get("standard")) + and relative_pattern_list(manifest.get("requiredFiles")) + and relative_pattern_list(manifest.get("governancePaths")) + and string_list(approvals, nonempty=True) + and set(approvals) <= { + "github-review", "github-app-review", "signed-attestation", + } + and approval_evidence_config_valid(manifest.get("approvalEvidence")) + and ticket_policy_valid(manifest.get("ticket")) + and docker_policy_valid(manifest.get("docker")) + ) + + +def basic_manifest_valid(manifest: Any) -> bool: + if not isinstance(manifest, dict) or manifest.get("schema") not in { + "new-project.governance/v1", "new-project.governance/v2", + }: + return False + common_valid = common_manifest_policy_valid(manifest) + if not common_valid or manifest["schema"] == "new-project.governance/v1": return common_valid + if "nonActiveStatuses" not in manifest["ticket"]: + return False allowed_root_keys = { "$schema", "schema", "standard", "requiredFiles", "governancePaths", - "trustedApprovalSources", "ticket", "docker", "coordination", "stacks", + "trustedApprovalSources", "approvalEvidence", "ticket", "docker", + "coordination", "delivery", "stacks", } coordination = manifest.get("coordination") + delivery = manifest.get("delivery") return ( set(manifest) <= allowed_root_keys and string_list(manifest.get("stacks", [])) and set(manifest.get("stacks", [])) <= {"node", "python", "go", "rust", "java", "docker", "frontend", "terraform", "kubernetes"} - and isinstance(coordination, dict) - and set(coordination) == {"mode", "maxActiveTicketsPerWorkstream", "rejectActiveScopeOverlap", "workstreams", "integration"} - and coordination.get("mode") == "workstreams" - and isinstance(coordination.get("maxActiveTicketsPerWorkstream"), int) - and not isinstance(coordination.get("maxActiveTicketsPerWorkstream"), bool) - and coordination["maxActiveTicketsPerWorkstream"] >= 1 - and isinstance(coordination.get("rejectActiveScopeOverlap"), bool) - and isinstance(coordination.get("workstreams"), dict) - and bool(coordination["workstreams"]) - and all( - isinstance(item, dict) - and set(item) == {"ownedPaths"} - and string_list(item.get("ownedPaths"), nonempty=True) - and all(relative_pattern(path) for path in item["ownedPaths"]) - for name, item in coordination["workstreams"].items() - if isinstance(name, str) and re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) - ) - and all(isinstance(name, str) and re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) for name in coordination["workstreams"]) - and isinstance(coordination.get("integration"), dict) - and set(coordination["integration"]) == {"workstream", "requiredForPaths"} - and isinstance(coordination["integration"].get("workstream"), str) - and string_list(coordination["integration"].get("requiredForPaths")) - and all(relative_pattern(item) for item in coordination["integration"]["requiredForPaths"]) - and coordination["integration"]["workstream"] in coordination["workstreams"] + and coordination_policy_valid(coordination) + and (delivery is None or delivery_policy_valid(delivery)) + ) + + +def lock_standard_valid(standard: Any, expected_version: str) -> bool: + return isinstance(standard, dict) and ( + set(standard) == {"id", "version", "sourceRepository", "sourceRevision", "publicationStatus"} + and standard.get("id") == "wellmanifest/new-project" + and standard.get("version") == expected_version + and standard.get("sourceRepository") == "wellmanifest/new-project" + and isinstance(standard.get("sourceRevision"), str) + and re.fullmatch(r"[0-9a-f]{40}", standard["sourceRevision"]) is not None + and standard.get("publicationStatus") == "published" ) -def check_lock(root: Path, lock_path: Path | None, manifest: dict[str, Any], report: Report) -> None: +def load_managed_lock(lock_path: Path, manifest: dict[str, Any]) -> dict[str, str]: + lock = load_json(lock_path) + managed = lock["managedFiles"] + if ( + lock.get("schema") != "new-project.lock/v1" + or set(lock) != {"schema", "standard", "managedFiles"} + or not isinstance(managed, dict) + ): + raise ValueError("unsupported lock schema") + if not lock_standard_valid(lock["standard"], manifest["standard"]["version"]): + raise ValueError("lock must identify the published immutable standard revision") + if not all( + isinstance(raw_path, str) + and relative_pattern(raw_path) + and isinstance(digest, str) + and re.fullmatch(r"[a-f0-9]{64}", digest) + for raw_path, digest in managed.items() + ): + raise ValueError("managedFiles must map repository-relative paths to lowercase SHA-256 digests") + return managed + + +def check_managed_file(root: Path, raw_path: str, expected: str, report: Report) -> None: + try: + path = safe_repo_path(root, raw_path) + except ValueError as error: + report.add("GOV-SYNC-001", str(error), "Use repository-relative managed paths.", [raw_path]) + return + actual = hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None + if actual != expected: + report.add( + "GOV-SYNC-001", f"Managed governance file digest differs: {raw_path}", + "Restore the pinned file or perform an explicit standard upgrade and regenerate the lock.", + [raw_path], {"expectedSha256": expected, "actualSha256": actual}, + ) + + +def check_lock( + root: Path, + lock_path: Path | None, + manifest: dict[str, Any], + report: Report, +) -> None: if lock_path is None: return if not lock_path.is_file(): @@ -426,46 +778,12 @@ def check_lock(root: Path, lock_path: Path | None, manifest: dict[str, Any], rep ) return try: - lock = load_json(lock_path) - managed = lock["managedFiles"] - standard = lock["standard"] - if lock.get("schema") != "new-project.lock/v1" or set(lock) != {"schema", "standard", "managedFiles"} or not isinstance(managed, dict): - raise ValueError("unsupported lock schema") - if ( - not isinstance(standard, dict) - or set(standard) != {"id", "version", "sourceRepository", "sourceRevision", "publicationStatus"} - or standard.get("id") != "wellmanifest/new-project" - or standard.get("version") != manifest["standard"]["version"] - or standard.get("sourceRepository") != "wellmanifest/new-project" - or not isinstance(standard.get("sourceRevision"), str) - or re.fullmatch(r"[0-9a-f]{40}", standard["sourceRevision"]) is None - or standard.get("publicationStatus") != "published" - ): - raise ValueError("lock must identify the published immutable standard revision") - if not all( - isinstance(raw_path, str) - and relative_pattern(raw_path) - and isinstance(digest, str) - and re.fullmatch(r"[a-f0-9]{64}", digest) - for raw_path, digest in managed.items() - ): - raise ValueError("managedFiles must map repository-relative paths to lowercase SHA-256 digests") + managed = load_managed_lock(lock_path, manifest) except (OSError, ValueError, KeyError, json.JSONDecodeError) as error: report.add("GOV-SYNC-001", f"Governance lock is invalid: {error}", "Regenerate the lock from a trusted standard release.", [rel(root, lock_path)]) return for raw_path, expected in sorted(managed.items()): - try: - path = safe_repo_path(root, raw_path) - except ValueError as error: - report.add("GOV-SYNC-001", str(error), "Use repository-relative managed paths.", [raw_path]) - continue - actual = hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None - if actual != expected: - report.add( - "GOV-SYNC-001", f"Managed governance file digest differs: {raw_path}", - "Restore the pinned file or perform an explicit standard upgrade and regenerate the lock.", - [raw_path], {"expectedSha256": expected, "actualSha256": actual}, - ) + check_managed_file(root, raw_path, expected, report) def parse_ticket_state(readme: Path) -> tuple[str | None, str | None]: @@ -492,46 +810,78 @@ def ticket_directories(root: Path, config: dict[str, Any]) -> list[Path]: ) -def validate_intent(path: Path, ticket_name: str) -> tuple[dict[str, Any] | None, str | None]: - try: - intent = load_json(path) - except (OSError, json.JSONDecodeError) as error: - return None, str(error) - v1_fields = {"schema", "ticket", "summary", "allowedPaths", "forbiddenPaths", "stacks"} - v2_fields = v1_fields | {"workstream", "dependsOn", "conflictsWith", "integrationTicket"} - if not isinstance(intent, dict) or intent.get("schema") not in { - "new-project.intent/v1", "new-project.intent/v2", - }: - return None, "unsupported intent schema" - expected = v2_fields if intent["schema"] == "new-project.intent/v2" else v1_fields - if set(intent) != expected: - return None, f"intent must contain exactly the {intent['schema'].rsplit('/', 1)[-1]} fields" +def intent_common_error(intent: dict[str, Any], ticket_name: str) -> str | None: if intent.get("ticket") != ticket_name: - return None, "intent schema or ticket identity differs" + return "intent schema or ticket identity differs" if not isinstance(intent.get("summary"), str) or not intent["summary"].strip(): - return None, "intent summary is blank" + return "intent summary is blank" for field_name in ("allowedPaths", "forbiddenPaths", "stacks"): if not string_list(intent.get(field_name)): - return None, f"intent {field_name} must be a list of non-blank strings" + return f"intent {field_name} must be a list of non-blank strings" if not intent["allowedPaths"]: - return None, "intent allowedPaths is empty" + return "intent allowedPaths is empty" for field_name in ("allowedPaths", "forbiddenPaths"): if not all(relative_pattern(value) for value in intent[field_name]): - return None, f"intent {field_name} must contain repository-relative patterns" + return f"intent {field_name} must contain repository-relative patterns" + return None + + +def ticket_id_list_error(intent: dict[str, Any], field_name: str) -> str | None: + values = intent.get(field_name) + if not isinstance(values, list) or not all( + isinstance(value, str) and re.fullmatch(r"ticket-[0-9]{3}", value) + for value in values + ): + return f"intent {field_name} must contain ticket IDs" + return f"intent {field_name} contains duplicates" if len(values) != len(set(values)) else None + + +def intent_v2_error(intent: dict[str, Any], ticket_name: str) -> str | None: + workstream = intent.get("workstream") + if not isinstance(workstream, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", workstream): + return "intent workstream is invalid" + for field_name in ("dependsOn", "conflictsWith"): + error = ticket_id_list_error(intent, field_name) + if error: + return error + integration = intent.get("integrationTicket") + if integration is not None and (not isinstance(integration, str) or not re.fullmatch(r"ticket-[0-9]{3}", integration)): + return "intent integrationTicket must be null or a ticket ID" + if integration == ticket_name: + return "intent integrationTicket cannot reference its own ticket" + return delivery_intent_error(intent["delivery"]) if "delivery" in intent else None + + +def intent_fields_error(intent: Any) -> str | None: + v1_fields = {"schema", "ticket", "summary", "allowedPaths", "forbiddenPaths", "stacks"} + v2_fields = v1_fields | {"workstream", "dependsOn", "conflictsWith", "integrationTicket"} + if not isinstance(intent, dict) or intent.get("schema") not in { + "new-project.intent/v1", "new-project.intent/v2", + }: + return "unsupported intent schema" + expected = v2_fields if intent["schema"] == "new-project.intent/v2" else v1_fields + allowed = [expected, expected | {"delivery"}] if intent["schema"] == "new-project.intent/v2" else [expected] + if set(intent) not in allowed: + return f"intent must contain exactly the {intent['schema'].rsplit('/', 1)[-1]} fields" + return None + + +def validate_intent(path: Path, ticket_name: str) -> tuple[dict[str, Any] | None, str | None]: + try: + intent = load_json(path) + except (OSError, json.JSONDecodeError) as error: + return None, str(error) + error = intent_fields_error(intent) + if error: + return None, error + assert isinstance(intent, dict) + error = intent_common_error(intent, ticket_name) + if error: + return None, error if intent["schema"] == "new-project.intent/v2": - if not isinstance(intent.get("workstream"), str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", intent["workstream"]): - return None, "intent workstream is invalid" - for field_name in ("dependsOn", "conflictsWith"): - values = intent.get(field_name) - if not isinstance(values, list) or not all(isinstance(value, str) and re.fullmatch(r"ticket-[0-9]{3}", value) for value in values): - return None, f"intent {field_name} must contain ticket IDs" - if len(values) != len(set(values)): - return None, f"intent {field_name} contains duplicates" - integration = intent.get("integrationTicket") - if integration is not None and (not isinstance(integration, str) or not re.fullmatch(r"ticket-[0-9]{3}", integration)): - return None, "intent integrationTicket must be null or a ticket ID" - if integration == ticket_name: - return None, "intent integrationTicket cannot reference its own ticket" + error = intent_v2_error(intent, ticket_name) + if error: + return None, error return intent, None @@ -553,34 +903,14 @@ def repository_files(root: Path, changed: list[str]) -> list[str]: return sorted(set([*files, *changed]) - {""}) -def check_coordination( +def valid_active_tickets( root: Path, - manifest: dict[str, Any], - records: list[TicketRecord], - changed: list[str], + config: dict[str, Any], + active: list[TicketRecord], + workstreams: dict[str, Any], report: Report, -) -> None: - coordination = manifest.get("coordination") - if not isinstance(coordination, dict): - return - config = manifest["ticket"] - active_statuses = set(config.get("activeStatuses", ACTIVE_DEFAULT)) - non_active_statuses = set(config.get("nonActiveStatuses", [])) - closed_statuses = set(config.get("closedStatuses", [])) - allowed_statuses = active_statuses | non_active_statuses | closed_statuses - for record in records: - if record.status not in allowed_statuses: - report.add( - "GOV-STATUS-001", f"Ticket {record.directory.name} has unknown status '{record.status or 'MISSING'}'.", - "Use a status declared in activeStatuses, nonActiveStatuses or closedStatuses.", - [rel(root, record.directory / "README.md")], - {"ticket": record.directory.name, "status": record.status, "allowedStatuses": sorted(allowed_statuses)}, - ) - active = [record for record in records if record.status in active_statuses] - by_name = {record.directory.name: record for record in records} - workstreams = coordination["workstreams"] - valid_active: list[TicketRecord] = [] - +) -> list[TicketRecord]: + valid: list[TicketRecord] = [] for record in active: intent_path = rel(root, record.directory / config["intentFile"]) if record.intent_error: @@ -604,9 +934,16 @@ def check_coordination( {"workstream": workstream, "knownWorkstreams": sorted(workstreams)}, ) continue - valid_active.append(record) + valid.append(record) + return valid + - limit = coordination["maxActiveTicketsPerWorkstream"] +def check_workstream_limits( + root: Path, + valid_active: list[TicketRecord], + limit: int, + report: Report, +) -> None: grouped: dict[str, list[TicketRecord]] = {} for record in valid_active: grouped.setdefault(record.intent["workstream"], []).append(record) # type: ignore[index] @@ -619,6 +956,13 @@ def check_coordination( {"workstream": workstream, "tickets": [member.directory.name for member in members], "limit": limit}, ) + +def dependency_graph( + root: Path, + records: list[TicketRecord], + config: dict[str, Any], + report: Report, +) -> dict[str, list[str]]: graph: dict[str, list[str]] = {} for record in records: if record.intent and record.intent.get("schema") == "new-project.intent/v2": @@ -628,7 +972,10 @@ def check_coordination( "GOV-DEPENDENCY-001", f"Ticket {record.directory.name} references itself as a dependency or conflict.", "Remove the self-reference and keep only directed edges to other tickets.", [rel(root, record.directory / config["intentFile"])], ) + return graph + +def find_dependency_cycle(graph: dict[str, list[str]]) -> list[str]: visiting: set[str] = set() visited: set[str] = set() cycle: list[str] = [] @@ -649,13 +996,41 @@ def visit(name: str, trail: list[str]) -> bool: for name in sorted(graph): if visit(name, [name]): - report.add( - "GOV-DEPENDENCY-001", "Ticket dependency graph contains a cycle.", - "Break the cycle by choosing a directed implementation order or an explicit integration ticket.", - [f"project/{item}/intent.json" for item in sorted(set(cycle))], {"cycle": cycle}, - ) - break + return cycle + return [] + +def check_dependency_cycle(graph: dict[str, list[str]], report: Report) -> None: + cycle = find_dependency_cycle(graph) + if cycle: + report.add( + "GOV-DEPENDENCY-001", "Ticket dependency graph contains a cycle.", + "Break the cycle by choosing a directed implementation order or an explicit integration ticket.", + [f"project/{item}/intent.json" for item in sorted(set(cycle))], {"cycle": cycle}, + ) + + +def integration_reference_valid(record: TicketRecord | None, required_workstream: str) -> bool: + return bool( + record is not None + and record.intent is not None + and record.intent.get("schema") == "new-project.intent/v2" + and record.intent.get("workstream") == required_workstream + and record.status != "CANCELLED" + ) + + +def check_active_relationships( + root: Path, + config: dict[str, Any], + coordination: dict[str, Any], + records: list[TicketRecord], + active: list[TicketRecord], + valid_active: list[TicketRecord], + report: Report, +) -> None: + closed_statuses = set(config.get("closedStatuses", [])) + by_name = {record.directory.name: record for record in records} active_names = {record.directory.name for record in active} conflict_pairs: set[tuple[str, str]] = set() integration_config = coordination["integration"] @@ -676,13 +1051,7 @@ def visit(name: str, trail: list[str]) -> bool: integration_name = record.intent["integrationTicket"] if integration_name is not None: integration_record = by_name.get(integration_name) - valid_integration = ( - integration_record is not None - and integration_record.intent is not None - and integration_record.intent.get("schema") == "new-project.intent/v2" - and integration_record.intent.get("workstream") == integration_config["workstream"] - and integration_record.status != "CANCELLED" - ) + valid_integration = integration_reference_valid(integration_record, integration_config["workstream"]) if not valid_integration: report.add( "GOV-INTEGRATION-001", @@ -698,8 +1067,16 @@ def visit(name: str, trail: list[str]) -> bool: [f"project/{first}/intent.json", f"project/{second}/intent.json"], ) - files = repository_files(root, changed) - governance_patterns = manifest["governancePaths"] + +def check_workstream_claims( + root: Path, + config: dict[str, Any], + workstreams: dict[str, Any], + governance_patterns: list[str], + files: list[str], + valid_active: list[TicketRecord], + report: Report, +) -> None: for record in valid_active: assert record.intent is not None owned_paths = workstreams[record.intent["workstream"]]["ownedPaths"] @@ -732,65 +1109,137 @@ def visit(name: str, trail: list[str]) -> bool: }, ) - if coordination["rejectActiveScopeOverlap"]: - for index, first in enumerate(valid_active): - assert first.intent is not None - for second in valid_active[index + 1:]: - assert second.intent is not None - shared_files = [ - path for path in files - if not matches(path, governance_patterns) - and matches(path, first.intent["allowedPaths"]) - and not matches(path, first.intent["forbiddenPaths"]) - and matches(path, second.intent["allowedPaths"]) - and not matches(path, second.intent["forbiddenPaths"]) - ] - first_patterns = [pattern for pattern in first.intent["allowedPaths"] if not matches(pattern, governance_patterns)] - second_patterns = [pattern for pattern in second.intent["allowedPaths"] if not matches(pattern, governance_patterns)] - overlapping_patterns = sorted({ - f"{first_pattern} <-> {second_pattern}" - for first_pattern in first_patterns - for second_pattern in second_patterns - if patterns_may_overlap(first_pattern, second_pattern) - }) - if shared_files or overlapping_patterns: - report.add( - "GOV-WORKSTREAM-004", - f"Active ticket scopes overlap: {first.directory.name} and {second.directory.name}.", - "Narrow one allowedPaths declaration, serialize the work, or route the shared contract through integration.", - shared_files[:20], - {"tickets": [first.directory.name, second.directory.name], "overlappingPatterns": overlapping_patterns, "concretePathCount": len(shared_files)}, - ) +def ticket_shared_files( + first: TicketRecord, + second: TicketRecord, + files: list[str], + governance_patterns: list[str], +) -> list[str]: + assert first.intent is not None and second.intent is not None + return [ + path for path in files + if not matches(path, governance_patterns) + and matches(path, first.intent["allowedPaths"]) + and not matches(path, first.intent["forbiddenPaths"]) + and matches(path, second.intent["allowedPaths"]) + and not matches(path, second.intent["forbiddenPaths"]) + ] -def check_required_files(root: Path, manifest: dict[str, Any], report: Report) -> None: - missing = [] - for raw in manifest["requiredFiles"]: - try: - if not safe_repo_path(root, raw).exists(): - missing.append(raw) - except ValueError: - missing.append(raw) - if missing: - report.add("GOV-BOOT-001", "Required target-repository files are missing.", "Run the approved new-project bootstrap before implementation.", missing) - docker = manifest["docker"] - if docker["required"]: - def first_repo_file(names: list[str]) -> str | None: - for name in names: - try: - if safe_repo_path(root, name).is_file(): - return name - except ValueError: - continue - return None +def ticket_overlapping_patterns( + first: TicketRecord, + second: TicketRecord, + governance_patterns: list[str], +) -> list[str]: + assert first.intent is not None and second.intent is not None + first_patterns = [pattern for pattern in first.intent["allowedPaths"] if not matches(pattern, governance_patterns)] + second_patterns = [pattern for pattern in second.intent["allowedPaths"] if not matches(pattern, governance_patterns)] + return sorted({ + f"{first_pattern} <-> {second_pattern}" + for first_pattern in first_patterns + for second_pattern in second_patterns + if patterns_may_overlap(first_pattern, second_pattern) + }) + + +def check_scope_overlaps( + valid_active: list[TicketRecord], + files: list[str], + governance_patterns: list[str], + report: Report, +) -> None: + for index, first in enumerate(valid_active): + for second in valid_active[index + 1:]: + shared_files = ticket_shared_files(first, second, files, governance_patterns) + overlapping_patterns = ticket_overlapping_patterns(first, second, governance_patterns) + if shared_files or overlapping_patterns: + report.add( + "GOV-WORKSTREAM-004", + f"Active ticket scopes overlap: {first.directory.name} and {second.directory.name}.", + "Narrow one allowedPaths declaration, serialize the work, or route the shared contract through integration.", + shared_files[:20], + {"tickets": [first.directory.name, second.directory.name], "overlappingPatterns": overlapping_patterns, "concretePathCount": len(shared_files)}, + ) - dockerfile = first_repo_file(docker["dockerfiles"]) - compose = first_repo_file(docker["composeFiles"]) - if dockerfile is None or compose is None: - report.add( - "GOV-DOCKER-001", "Required Dockerfile or Compose declaration is missing.", - "Add a pinned Docker runtime and validate its Compose configuration.", + +def check_ticket_statuses( + root: Path, + config: dict[str, Any], + records: list[TicketRecord], + report: Report, +) -> None: + allowed = set(config.get("activeStatuses", ACTIVE_DEFAULT)) + allowed.update(config.get("nonActiveStatuses", [])) + allowed.update(config.get("closedStatuses", [])) + for record in records: + if record.status not in allowed: + report.add( + "GOV-STATUS-001", + f"Ticket {record.directory.name} has unknown status '{record.status or 'MISSING'}'.", + "Use a status declared in activeStatuses, nonActiveStatuses or closedStatuses.", + [rel(root, record.directory / "README.md")], + { + "ticket": record.directory.name, + "status": record.status, + "allowedStatuses": sorted(allowed), + }, + ) + + +def check_coordination( + root: Path, + manifest: dict[str, Any], + records: list[TicketRecord], + changed: list[str], + report: Report, +) -> None: + coordination = manifest.get("coordination") + if not isinstance(coordination, dict): + return + config = manifest["ticket"] + check_ticket_statuses(root, config, records, report) + active = [record for record in records if record.status in set(config.get("activeStatuses", ACTIVE_DEFAULT))] + workstreams = coordination["workstreams"] + valid_active = valid_active_tickets(root, config, active, workstreams, report) + check_workstream_limits(root, valid_active, coordination["maxActiveTicketsPerWorkstream"], report) + check_dependency_cycle(dependency_graph(root, records, config, report), report) + check_active_relationships(root, config, coordination, records, active, valid_active, report) + files = repository_files(root, changed) + governance_patterns = manifest["governancePaths"] + check_workstream_claims(root, config, workstreams, governance_patterns, files, valid_active, report) + if coordination["rejectActiveScopeOverlap"]: + check_scope_overlaps(valid_active, files, governance_patterns, report) + + +def check_required_files(root: Path, manifest: dict[str, Any], report: Report) -> None: + missing = [] + for raw in manifest["requiredFiles"]: + try: + if not safe_repo_path(root, raw).exists(): + missing.append(raw) + except ValueError: + missing.append(raw) + if missing: + report.add("GOV-BOOT-001", "Required target-repository files are missing.", "Run the approved new-project bootstrap before implementation.", missing) + + docker = manifest["docker"] + if docker["required"]: + def first_repo_file(names: list[str]) -> str | None: + for name in names: + try: + if safe_repo_path(root, name).is_file(): + return name + except ValueError: + continue + return None + + dockerfile = first_repo_file(docker["dockerfiles"]) + compose = first_repo_file(docker["composeFiles"]) + if dockerfile is None or compose is None: + report.add( + "GOV-DOCKER-001", "Required Dockerfile or Compose declaration is missing.", + "Add a pinned Docker runtime and validate its Compose configuration.", [*([] if dockerfile else docker["dockerfiles"]), *([] if compose else docker["composeFiles"])], ) @@ -840,6 +1289,40 @@ def check_ticket_content(root: Path, directories: list[Path], config: dict[str, ) +def probable_secret_fields(text: str) -> list[str]: + fields = [] + for match in SECRET_RE.finditer(text): + shell_assignment = text[match.end(2):].startswith("=") + environment_reference = re.match(r"^[A-Z][A-Z0-9_]*=", match.group(2)) + if not shell_assignment and not environment_reference and not SAFE_SECRET_VALUES.match(match.group(2)): + fields.append(match.group(1)) + return sorted(set(fields)) + + +def check_changed_file(root: Path, raw: str, report: Report) -> None: + try: + path = safe_repo_path(root, raw) + except ValueError: + return + if not path.is_file() or path.stat().st_size > 1_000_000: + return + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return + secrets = probable_secret_fields(text) + if secrets: + report.add( + "GOV-SECRET-001", f"Probable secret assignment detected in {raw}.", + "Remove and rotate the secret; keep only placeholders in tracked files.", [raw], {"fieldNames": secrets}, + ) + if raw.startswith(("project/ticket-", ".governance/")) and LOCAL_PATH_RE.search(text): + report.add( + "GOV-PATH-001", f"Machine-local absolute path detected in governed artifact: {raw}", + "Replace it with a repository-relative path before publication.", [raw], + ) + + def check_changed_content(root: Path, changed: list[str], actor: str, trusted_human_change: bool, report: Report) -> None: human_paths = [path for path in changed if fnmatch.fnmatchcase(path, "project/ticket-*/user-*.md")] if human_paths and (actor != "human" or not trusted_human_change): @@ -848,61 +1331,309 @@ def check_changed_content(root: Path, changed: list[str], actor: str, trusted_hu "Revert the agent edit or have the human owner submit it through the trusted intake boundary.", human_paths, ) for raw in changed: + check_changed_file(root, raw, report) + + +def check_declared_delivery_budget( + policy: dict[str, Any], + delivery: dict[str, Any], + record: TicketRecord, + intent_path: str, + report: Report, +) -> None: + complexity_limit = 10 if delivery["complexity"] == "XS" else policy["maxActiveMinutes"] + declared_limits = delivery["budgets"] + policy_limits = { + "maxImplementationFiles": policy["maxImplementationFiles"], + "maxAffectedComponents": policy["maxAffectedComponents"], + "maxPublicInterfaceChanges": policy["maxPublicInterfaceChanges"], + "maxRuntimeDependencies": policy["maxRuntimeDependencies"], + } + violations = { + name: {"declared": declared_limits[name], "policy": limit} + for name, limit in policy_limits.items() + if declared_limits[name] > limit + } + if ( + delivery["complexity"] not in policy["allowedComplexityClasses"] + or delivery["estimatedMinutes"] > policy["maxActiveMinutes"] + or delivery["estimatedMinutes"] > complexity_limit + or violations + ): + report.add( + "GOV-DELIVERY-001", + f"Ticket {record.directory.name} exceeds the approved delivery class or policy budget.", + "Split the outcome into dependent XS/S slices; do not widen the current ticket or PR.", + [intent_path], + { + "complexity": delivery["complexity"], + "estimatedMinutes": delivery["estimatedMinutes"], + "maxActiveMinutes": policy["maxActiveMinutes"], + "budgetViolations": violations, + }, + ) + + +def check_delivery_timebox( + policy: dict[str, Any], + record: TicketRecord, + intent_path: str, + elapsed_minutes: int | None, + report: Report, +) -> None: + if elapsed_minutes is not None: + if elapsed_minutes >= policy["maxActiveMinutes"]: + report.add( + "GOV-DELIVERY-001", + f"Ticket {record.directory.name} reached its {policy['maxActiveMinutes']}-minute implementation timebox.", + "Stop implementation, preserve evidence and plan unfinished work as an explicit dependent slice.", + [intent_path], {"elapsedMinutes": elapsed_minutes}, + ) + elif elapsed_minutes >= policy["checkpointMinutes"]: + report.add( + "GOV-DELIVERY-002", + f"Ticket {record.directory.name} reached its delivery checkpoint.", + "Record completed and remaining scope now; stop at the hard timebox instead of expanding the diff.", + [intent_path], {"elapsedMinutes": elapsed_minutes, "stopAtMinutes": policy["maxActiveMinutes"]}, + severity="warning", + ) + + +def check_delivery_base( + root: Path, + policy: dict[str, Any], + delivery: dict[str, Any], + record: TicketRecord, + intent_path: str, + base: str | None, + report: Report, +) -> None: + if delivery["targetBranch"] not in policy["targetBranches"]: + report.add( + "GOV-BASE-001", + f"Ticket {record.directory.name} targets unapproved branch '{delivery['targetBranch']}'.", + "Choose a manifest-approved target branch and obtain fresh approval for its exact base SHA.", + [intent_path], {"allowedTargets": policy["targetBranches"]}, + ) + + accepted_sha = delivery["acceptedBaseSha"] + observed_base = None + if base: try: - path = safe_repo_path(root, raw) - except ValueError: - continue - if not path.is_file() or path.stat().st_size > 1_000_000: - continue - try: - text = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue - secrets = [] - for match in SECRET_RE.finditer(text): - if text[match.end(2):].startswith("="): - continue - if re.match(r"^[A-Z][A-Z0-9_]*=", match.group(2)): - continue - if not SAFE_SECRET_VALUES.match(match.group(2)): - secrets.append(match.group(1)) - if secrets: + observed_base = git_output(root, ["rev-parse", f"{base}^{{commit}}"]).decode().strip() + except (subprocess.CalledProcessError, FileNotFoundError): report.add( - "GOV-SECRET-001", f"Probable secret assignment detected in {raw}.", - "Remove and rotate the secret; keep only placeholders in tracked files.", [raw], {"fieldNames": sorted(set(secrets))}, + "GOV-BASE-001", "The supplied base revision cannot be resolved.", + "Fetch the complete target history and rerun against the exact accepted base SHA.", + [intent_path], {"suppliedBase": base, "acceptedBaseSha": accepted_sha}, ) - if raw.startswith(("project/ticket-", ".governance/")) and LOCAL_PATH_RE.search(text): + if observed_base and observed_base != accepted_sha: + report.add( + "GOV-BASE-001", f"Ticket {record.directory.name} approval is bound to a stale or different base SHA.", + "Refresh the branch, update architecture/scope evidence and obtain fresh approval before continuing.", + [intent_path], {"acceptedBaseSha": accepted_sha, "observedBaseSha": observed_base}, + ) + + target_refs = [ + f"refs/remotes/origin/{delivery['targetBranch']}", + f"refs/heads/{delivery['targetBranch']}", + ] + for target_ref in target_refs: + try: + current_target = git_output(root, ["rev-parse", "--verify", f"{target_ref}^{{commit}}"]).decode().strip() + except (subprocess.CalledProcessError, FileNotFoundError): + continue + if current_target != accepted_sha: report.add( - "GOV-PATH-001", f"Machine-local absolute path detected in governed artifact: {raw}", - "Replace it with a repository-relative path before publication.", [raw], + "GOV-BASE-001", f"Target branch '{delivery['targetBranch']}' moved after ticket approval.", + "Refresh from the target, re-run conflict and validation checks, then obtain fresh approval if intent or architecture changed.", + [intent_path], {"acceptedBaseSha": accepted_sha, "currentTargetSha": current_target, "targetRef": target_ref}, ) + break + + +def map_implementation_components( + implementation: list[str], + components: list[dict[str, Any]], +) -> tuple[list[str], list[str], set[str]]: + unmapped: list[str] = [] + multiply_mapped: list[str] = [] + touched_components: set[str] = set() + for path in implementation: + owners = [component["name"] for component in components if matches(path, component["paths"])] + if not owners: + unmapped.append(path) + elif len(owners) > 1: + multiply_mapped.append(path) + else: + touched_components.add(owners[0]) + return unmapped, multiply_mapped, touched_components -def check_change_gate( +def check_delivery_architecture( + policy: dict[str, Any], + delivery: dict[str, Any], + record: TicketRecord, + implementation: list[str], + intent_path: str, + report: Report, +) -> set[str]: + declared_limits = delivery["budgets"] + architecture = delivery["architecture"] + components = architecture["components"] + component_overflow = len(components) > min( + declared_limits["maxAffectedComponents"], policy["maxAffectedComponents"], + ) + interface_overflow = len(architecture["interfaceChanges"]) > min( + declared_limits["maxPublicInterfaceChanges"], policy["maxPublicInterfaceChanges"], + ) + dependency_overflow = len(delivery["runtimeDependencies"]) > min( + declared_limits["maxRuntimeDependencies"], policy["maxRuntimeDependencies"], + ) + unmapped, multiply_mapped, touched_components = map_implementation_components(implementation, components) + if component_overflow or unmapped or multiply_mapped: + report.add( + "GOV-ARCHITECTURE-001", + f"Ticket {record.directory.name} has unresolved or ambiguous component ownership.", + "Decide component ownership before EDIT; map every changed implementation path to exactly one approved component.", + [intent_path, *unmapped, *multiply_mapped], + { + "declaredComponents": [component["name"] for component in components], + "touchedComponents": sorted(touched_components), + "unmappedPaths": unmapped, + "multiplyMappedPaths": multiply_mapped, + }, + ) + check_actual_delivery_budget( + policy, delivery, record, implementation, touched_components, + interface_overflow, dependency_overflow, report, + ) + return touched_components + + +def check_actual_delivery_budget( + policy: dict[str, Any], + delivery: dict[str, Any], + record: TicketRecord, + implementation: list[str], + touched_components: set[str], + interface_overflow: bool, + dependency_overflow: bool, + report: Report, +) -> None: + declared_limits = delivery["budgets"] + implementation_limit = min(declared_limits["maxImplementationFiles"], policy["maxImplementationFiles"]) + public_paths = [path for path in implementation if matches(path, policy["publicInterfacePaths"])] + dependency_paths = [path for path in implementation if path in policy["dependencyManifestPaths"]] + if ( + len(implementation) > implementation_limit + or len(touched_components) > declared_limits["maxAffectedComponents"] + or interface_overflow + or dependency_overflow + or len(public_paths) > declared_limits["maxPublicInterfaceChanges"] + ): + report.add( + "GOV-BUDGET-001", + f"Actual diff for {record.directory.name} exceeds its approved complexity budget.", + "Stop and split the remaining outcome into an explicitly dependent ticket; do not enlarge the current PR.", + implementation, + { + "implementationFiles": len(implementation), + "implementationFileLimit": implementation_limit, + "touchedComponents": sorted(touched_components), + "publicInterfacePaths": public_paths, + "dependencyManifestPaths": dependency_paths, + "declaredRuntimeDependencies": delivery["runtimeDependencies"], + }, + ) + + +def check_integration_ownership( + manifest: dict[str, Any], + delivery: dict[str, Any], + record: TicketRecord, + intent_path: str, + report: Report, +) -> None: + integration_workstream = manifest["coordination"]["integration"]["workstream"] + architecture = delivery["architecture"] + if (architecture["responsibilityChanges"] or architecture["dataChanges"]) and record.intent["workstream"] != integration_workstream: + report.add( + "GOV-ARCHITECTURE-001", + "Responsibility or persistent-data movement is not owned by an integration slice.", + "Create and approve a <=30-minute integration-workstream slice before changing component ownership or persistent data.", + [intent_path], + {"workstream": record.intent["workstream"], "requiredWorkstream": integration_workstream}, + ) + + +def check_delivery_gate( root: Path, manifest: dict[str, Any], - records: list[TicketRecord], - changed: list[str], + record: TicketRecord, + implementation: list[str], base: str | None, - head: str, - approval_source: str | None, - approved_ticket: str | None, - enforce_approval: bool, + elapsed_minutes: int | None, report: Report, ) -> None: - governance_patterns = manifest["governancePaths"] - implementation = [path for path in changed if not matches(path, governance_patterns)] - if not implementation: + policy = manifest.get("delivery") + if not isinstance(policy, dict) or not policy.get("requiredForImplementation"): return - config = manifest["ticket"] - active = [record for record in records if record.status in set(config.get("activeStatuses", ACTIVE_DEFAULT))] + assert record.intent is not None + delivery = record.intent.get("delivery") + intent_path = rel(root, record.directory / manifest["ticket"]["intentFile"]) + if not isinstance(delivery, dict): + report.add( + "GOV-DELIVERY-001", + f"Implementation ticket {record.directory.name} has no bounded delivery contract.", + "Return to WAIT_FOR_APPROVAL, declare one <=30-minute XS/S outcome with architecture and validation evidence, then obtain fresh approval.", + [intent_path], + ) + return + check_declared_delivery_budget(policy, delivery, record, intent_path, report) + check_delivery_timebox(policy, record, intent_path, elapsed_minutes, report) + check_delivery_base(root, policy, delivery, record, intent_path, base, report) + check_delivery_architecture(policy, delivery, record, implementation, intent_path, report) + check_integration_ownership(manifest, delivery, record, intent_path, report) + + +def ticket_owns_implementation(record: TicketRecord, implementation: list[str]) -> bool: + return bool( + record.intent is not None + and record.intent.get("schema") == "new-project.intent/v2" + and all( + matches(path, record.intent["allowedPaths"]) + and not matches(path, record.intent["forbiddenPaths"]) + for path in implementation + ) + ) + + +def ticket_path_owners(active: list[TicketRecord], implementation: list[str]) -> dict[str, list[str]]: + return { + path: [ + record.directory.name for record in active + if record.intent is not None + and matches(path, record.intent["allowedPaths"]) + and not matches(path, record.intent["forbiddenPaths"]) + ] + for path in implementation + } + + +def select_change_ticket( + root: Path, + active: list[TicketRecord], + coordination: Any, + implementation: list[str], + report: Report, +) -> TicketRecord | None: if not active: report.add( "GOV-TICKET-001", "Implementation paths changed without an active ticket.", "Create the next target-repository ticket, publish its plan and obtain approval before editing implementation.", implementation, ) - return - coordination = manifest.get("coordination") + return None if not isinstance(coordination, dict): if len(active) > 1: report.add( @@ -910,39 +1641,32 @@ def check_change_gate( "Continue the existing ticket or close/cancel it before creating another.", [rel(root, item.directory) for item in active], {"tickets": [item.directory.name for item in active]}, ) - return - selected = active[0] - else: - candidates = [ - record for record in active - if record.intent is not None - and record.intent.get("schema") == "new-project.intent/v2" - and all( - matches(path, record.intent["allowedPaths"]) - and not matches(path, record.intent["forbiddenPaths"]) - for path in implementation - ) - ] - if len(candidates) == 1: - selected = candidates[0] - elif not candidates and len(active) == 1: - selected = active[0] - else: - path_owners = { - path: [ - record.directory.name for record in active - if record.intent is not None - and matches(path, record.intent["allowedPaths"]) - and not matches(path, record.intent["forbiddenPaths"]) - ] - for path in implementation - } - report.add( - "GOV-TICKET-005", "Implementation diff does not resolve to exactly one active ticket.", - "Use one ticket per branch/PR, narrow allowedPaths, or create an approved integration ticket for the combined diff.", - implementation, {"candidateTickets": [record.directory.name for record in candidates], "pathOwners": path_owners}, - ) - return + return None + return active[0] + candidates = [record for record in active if ticket_owns_implementation(record, implementation)] + if len(candidates) == 1: + return candidates[0] + if not candidates and len(active) == 1: + return active[0] + path_owners = ticket_path_owners(active, implementation) + report.add( + "GOV-TICKET-005", "Implementation diff does not resolve to exactly one active ticket.", + "Use one ticket per branch/PR, narrow allowedPaths, or create an approved integration ticket for the combined diff.", + implementation, {"candidateTickets": [record.directory.name for record in candidates], "pathOwners": path_owners}, + ) + return None + + +def check_selected_ticket_state( + root: Path, + config: dict[str, Any], + selected: TicketRecord, + implementation: list[str], + base: str | None, + head: str, + governance_patterns: list[str], + report: Report, +) -> None: directory = selected.directory workflow = selected.workflow check_history_order( @@ -956,6 +1680,56 @@ def check_change_gate( "GOV-INTENT-001", f"Ticket {directory.name} is in workflow state {workflow or 'UNKNOWN'}, not an implementation state.", "Keep the change plan-only until explicit approval moves the ticket to EDIT.", implementation, ) + + +def check_workstream_change_scope( + records: list[TicketRecord], + coordination: dict[str, Any], + selected: TicketRecord, + implementation: list[str], + report: Report, +) -> None: + intent = selected.intent + assert intent is not None + workstream = coordination["workstreams"].get(intent["workstream"]) + if isinstance(workstream, dict): + unowned = [path for path in implementation if not matches(path, workstream["ownedPaths"])] + if unowned: + report.add( + "GOV-WORKSTREAM-003", f"Changed paths are not owned by workstream '{intent['workstream']}'.", + "Move the change to its owning workstream or create and approve an integration ticket; do not widen ownership retroactively.", + unowned, {"ticket": selected.directory.name, "workstream": intent["workstream"], "ownedPaths": workstream["ownedPaths"]}, + ) + integration = coordination["integration"] + shared = [path for path in implementation if matches(path, integration["requiredForPaths"])] + if shared and intent["workstream"] != integration["workstream"]: + integration_name = intent["integrationTicket"] + integration_record = next((record for record in records if record.directory.name == integration_name), None) + report.add( + "GOV-INTEGRATION-001", "Shared contract paths must be changed by the integration-workstream ticket.", + "Move the shared-path diff to the referenced integration ticket's branch; integrationTicket coordinates work but does not transfer path ownership.", + shared, + { + "ticket": selected.directory.name, + "integrationTicket": integration_name, + "validIntegrationReference": integration_reference_valid(integration_record, integration["workstream"]), + "requiredWorkstream": integration["workstream"], + }, + ) + + +def check_selected_ticket_intent( + root: Path, + manifest: dict[str, Any], + records: list[TicketRecord], + selected: TicketRecord, + implementation: list[str], + base: str | None, + elapsed_minutes: int | None, + report: Report, +) -> None: + directory = selected.directory + config = manifest["ticket"] intent_path = directory / config["intentFile"] intent, error = selected.intent, selected.intent_error if error: @@ -968,54 +1742,310 @@ def check_change_gate( "Revert the paths or return to PLAN, expand allowedPaths and obtain fresh approval.", outside, {"ticket": directory.name, "allowedPaths": intent["allowedPaths"]}, ) + coordination = manifest.get("coordination") if isinstance(coordination, dict) and intent.get("schema") == "new-project.intent/v2": - workstream = coordination["workstreams"].get(intent["workstream"]) - if isinstance(workstream, dict): - unowned = [path for path in implementation if not matches(path, workstream["ownedPaths"])] - if unowned: - report.add( - "GOV-WORKSTREAM-003", f"Changed paths are not owned by workstream '{intent['workstream']}'.", - "Move the change to its owning workstream or create and approve an integration ticket; do not widen ownership retroactively.", - unowned, {"ticket": directory.name, "workstream": intent["workstream"], "ownedPaths": workstream["ownedPaths"]}, - ) - integration = coordination["integration"] - shared = [path for path in implementation if matches(path, integration["requiredForPaths"])] - if shared and intent["workstream"] != integration["workstream"]: - integration_name = intent["integrationTicket"] - integration_record = next((record for record in records if record.directory.name == integration_name), None) - valid_integration = ( - integration_record is not None - and integration_record.intent is not None - and integration_record.intent.get("schema") == "new-project.intent/v2" - and integration_record.intent.get("workstream") == integration["workstream"] - and integration_record.status != "CANCELLED" - ) - report.add( - "GOV-INTEGRATION-001", "Shared contract paths must be changed by the integration-workstream ticket.", - "Move the shared-path diff to the referenced integration ticket's branch; integrationTicket coordinates work but does not transfer path ownership.", - shared, - { - "ticket": directory.name, - "integrationTicket": integration_name, - "validIntegrationReference": valid_integration, - "requiredWorkstream": integration["workstream"], - }, - ) + check_workstream_change_scope(records, coordination, selected, implementation, report) + if intent is not None: + check_delivery_gate(root, manifest, selected, implementation, base, elapsed_minutes, report) + + +def approval_subject_valid(evidence: Any) -> bool: + required = { + "schema", "source", "repository", "pullRequest", "headSha", "ticket", + "actor", "verification", + } + return ( + isinstance(evidence, dict) + and set(evidence) == required + and evidence.get("schema") == "new-project.approval-evidence/v1" + and evidence.get("source") in { + "github-review", "github-app-review", "signed-attestation", + } + and isinstance(evidence.get("repository"), str) + and re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", evidence["repository"]) is not None + and isinstance(evidence.get("pullRequest"), int) + and not isinstance(evidence.get("pullRequest"), bool) + and evidence["pullRequest"] >= 1 + and isinstance(evidence.get("headSha"), str) + and re.fullmatch(r"[0-9a-f]{40}", evidence["headSha"]) is not None + and isinstance(evidence.get("ticket"), str) + and re.fullmatch(r"ticket-[0-9]{3}", evidence["ticket"]) is not None + ) + + +def approval_actor_valid(actor: Any) -> bool: + return ( + isinstance(actor, dict) + and set(actor) == {"login", "type"} + and isinstance(actor.get("login"), str) + and bool(actor["login"]) + and actor.get("type") in {"User", "Bot", "Workflow"} + ) + + +def approval_verification_valid(verification: Any) -> bool: + return ( + isinstance(verification, dict) + and {"method", "verified"} <= set(verification) + and set(verification) <= {"method", "verified", "issuer", "predicateType"} + and verification.get("method") in { + "github-api-allowlist", "github-attestation", "sigstore", + } + and verification.get("verified") is True + ) + + +def approval_authority_valid( + evidence: dict[str, Any], + manifest: dict[str, Any], +) -> bool: + source = evidence["source"] + actor = evidence["actor"] + verification = evidence["verification"] + method = verification["method"] + if source == "github-review": + return actor["type"] == "User" and method == "github-api-allowlist" + if source == "github-app-review": + return ( + actor["type"] == "Bot" + and actor["login"].endswith("[bot]") + and method == "github-api-allowlist" + ) + approval_config = manifest.get("approvalEvidence") or {} + expected_predicate = approval_config.get( + "signedAttestationPredicateType", + "https://wellmanifest.dev/attestations/validator/v1", + ) + return ( + actor["type"] in {"Bot", "Workflow"} + and method in {"github-attestation", "sigstore"} + and isinstance(verification.get("issuer"), str) + and bool(verification["issuer"]) + and verification.get("predicateType") == expected_predicate + ) + + +def approval_binding_mismatches( + evidence: dict[str, Any], + expected_repository: str | None, + expected_pull_request: int | None, + expected_head: str | None, +) -> tuple[bool, dict[str, dict[str, Any]]]: + missing = ( + expected_repository is None + or expected_pull_request is None + or expected_head is None + or re.fullmatch(r"[0-9a-f]{40}", expected_head or "") is None + ) + expected = { + "repository": expected_repository, + "pullRequest": expected_pull_request, + "headSha": expected_head, + } + mismatches = { + name: {"evidence": evidence[name], "expected": value} + for name, value in expected.items() + if evidence[name] != value + } + return missing, mismatches + + +def load_external_approval_evidence( + root: Path, + raw_path: str | None, + report: Report, +) -> Any | None: + if not raw_path: + return None + expanded = Path(raw_path).expanduser() + if not expanded.is_absolute(): + expanded = Path.cwd() / expanded + try: + path = expanded.parent.resolve(strict=True) / expanded.name + except OSError as error: + report.add( + "GOV-APPROVAL-003", f"Approval evidence path is unreadable: {error}", + "Have the protected approval resolver create a valid v1 evidence document outside the checkout.", + ) + return None + if path.is_relative_to(root): + report.add( + "GOV-APPROVAL-003", + "Approval evidence is controlled by the pull-request checkout.", + "Create evidence outside the checkout from a protected workflow after API or signature verification.", + [rel(root, path)], + ) + return None + no_follow = getattr(os, "O_NOFOLLOW", None) + if no_follow is None: + report.add( + "GOV-APPROVAL-003", + "Approval evidence cannot be opened safely on this platform.", + "Use a validator platform that supports no-follow file opens for external approval evidence.", + ) + return None + descriptor = -1 + try: + flags = os.O_RDONLY | no_follow | getattr(os, "O_CLOEXEC", 0) + descriptor = os.open(path, flags) + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + raise OSError("approval evidence is not a regular file") + with os.fdopen(descriptor, "r", encoding="utf-8") as handle: + descriptor = -1 + evidence = json.load(handle) + except (OSError, json.JSONDecodeError) as error: + report.add( + "GOV-APPROVAL-003", f"Approval evidence is unreadable: {error}", + "Have the protected approval resolver create a valid v1 evidence document outside the checkout.", + ) + return None + finally: + if descriptor >= 0: + os.close(descriptor) + return evidence + + +def approval_evidence( + root: Path, + raw_path: str | None, + manifest: dict[str, Any], + expected_repository: str | None, + expected_pull_request: int | None, + expected_head: str | None, + report: Report, +) -> dict[str, Any] | None: + evidence = load_external_approval_evidence(root, raw_path, report) + if evidence is None: + return None + actor = evidence.get("actor") if isinstance(evidence, dict) else None + verification = evidence.get("verification") if isinstance(evidence, dict) else None + if not ( + approval_subject_valid(evidence) + and approval_actor_valid(actor) + and approval_verification_valid(verification) + ): + report.add( + "GOV-APPROVAL-003", "Approval evidence does not conform to new-project.approval-evidence/v1.", + "Regenerate evidence with the protected resolver and the pinned approval-evidence schema.", + ) + return None + missing, mismatches = approval_binding_mismatches( + evidence, expected_repository, expected_pull_request, expected_head, + ) + if missing or mismatches: + report.add( + "GOV-APPROVAL-004", + "Approval evidence is not bound to the current repository, pull request and HEAD.", + "Pass the current protected event bindings and request a fresh approval for the exact HEAD.", + evidence={"missingExpectedBinding": missing, "mismatches": mismatches}, + ) + return None + if not approval_authority_valid(evidence, manifest): + report.add( + "GOV-APPROVAL-005", + "Approval actor or verification method is not valid for the claimed source.", + "Use an allowlisted User, an allowlisted GitHub App bot login, or a signature-verified trusted attestation issuer.", + evidence={ + "source": evidence["source"], + "actor": evidence["actor"], + "verification": evidence["verification"], + }, + ) + return None + return evidence + + +def check_change_approval( + root: Path, + manifest: dict[str, Any], + selected: TicketRecord, + approval_source: str | None, + approved_ticket: str | None, + report: Report, +) -> None: + directory = selected.directory + trusted = set(manifest["trustedApprovalSources"]) + if approval_source not in trusted: + report.add( + "GOV-APPROVAL-001", "No trusted external approval was supplied for implementation.", + "Require an approving CODEOWNER GitHub review or signed attestation; Markdown status alone is not trusted.", + [rel(root, directory / "README.md")], {"suppliedSource": approval_source, "trustedSources": sorted(trusted)}, + ) + approved_tickets = set((approved_ticket or "").split(",")) - {""} + if directory.name not in approved_tickets: + report.add( + "GOV-APPROVAL-002", "Trusted approval does not identify the active ticket.", + "Approve the current ticket after reviewing its latest intent and implementation diff.", + [rel(root, directory)], {"activeTicket": directory.name, "approvedTickets": sorted(approved_tickets)}, + ) + + +def resolve_change_approval( + root: Path, + manifest: dict[str, Any], + selected: TicketRecord, + approval_source: str | None, + approved_ticket: str | None, + approval_evidence_path: str | None, + expected_repository: str | None, + expected_pull_request: int | None, + expected_head: str | None, + report: Report, +) -> None: + supplied = approval_evidence( + root, approval_evidence_path, manifest, expected_repository, + expected_pull_request, expected_head, report, + ) + if supplied is not None: + approval_source = supplied["source"] + approved_ticket = supplied["ticket"] + elif approval_source in {"github-app-review", "signed-attestation"}: + report.add( + "GOV-APPROVAL-003", + f"Approval source {approval_source} requires external v1 evidence.", + "Create bound evidence outside the checkout after allowlist or signature verification.", + ) + check_change_approval( + root, manifest, selected, approval_source, approved_ticket, report, + ) + + +def check_change_gate( + root: Path, + manifest: dict[str, Any], + records: list[TicketRecord], + changed: list[str], + base: str | None, + head: str, + approval_source: str | None, + approved_ticket: str | None, + approval_evidence_path: str | None, + expected_repository: str | None, + expected_pull_request: int | None, + expected_head: str | None, + enforce_approval: bool, + elapsed_minutes: int | None, + report: Report, +) -> str | None: + governance_patterns = manifest["governancePaths"] + implementation = [path for path in changed if not matches(path, governance_patterns)] + if not implementation: + return None + config = manifest["ticket"] + active = [record for record in records if record.status in set(config.get("activeStatuses", ACTIVE_DEFAULT))] + selected = select_change_ticket(root, active, manifest.get("coordination"), implementation, report) + if selected is None: + return None + check_selected_ticket_state(root, config, selected, implementation, base, head, governance_patterns, report) + check_selected_ticket_intent(root, manifest, records, selected, implementation, base, elapsed_minutes, report) if enforce_approval: - trusted = set(manifest["trustedApprovalSources"]) - if approval_source not in trusted: - report.add( - "GOV-APPROVAL-001", "No trusted external approval was supplied for implementation.", - "Require an approving CODEOWNER GitHub review or signed attestation; Markdown status alone is not trusted.", - [rel(root, directory / "README.md")], {"suppliedSource": approval_source, "trustedSources": sorted(trusted)}, - ) - approved_tickets = set((approved_ticket or "").split(",")) - {""} - if directory.name not in approved_tickets: - report.add( - "GOV-APPROVAL-002", "Trusted approval does not identify the active ticket.", - "Approve the current ticket after reviewing its latest intent and implementation diff.", - [rel(root, directory)], {"activeTicket": directory.name, "approvedTickets": sorted(approved_tickets)}, - ) + resolve_change_approval( + root, manifest, selected, approval_source, approved_ticket, + approval_evidence_path, expected_repository, expected_pull_request, + expected_head, report, + ) + return selected.directory.name def sarif(payload: dict[str, Any]) -> dict[str, Any]: @@ -1074,80 +2104,140 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--enforce-approval", action="store_true") parser.add_argument("--approval-source") parser.add_argument("--approved-ticket") + parser.add_argument("--approval-evidence") + parser.add_argument("--expected-repository") + parser.add_argument("--expected-pull-request", type=int) + parser.add_argument("--expected-head") + parser.add_argument("--resolved-ticket-output") + parser.add_argument("--elapsed-minutes", type=int) parser.add_argument("--format", choices=["text", "json", "sarif"], default="text") parser.add_argument("--output") return parser.parse_args(argv) -def main(argv: list[str] | None = None) -> int: - args = parse_args(argv or sys.argv[1:]) - root = Path(args.root).resolve() - report = Report(root) +def load_manifest(root: Path, raw_path: str, report: Report) -> dict[str, Any] | None: try: - manifest_path = safe_repo_path(root, args.manifest) + manifest_path = safe_repo_path(root, raw_path) except ValueError as error: report.add("GOV-MANIFEST-001", str(error), "Use a repository-relative manifest path.") - manifest = None - else: - try: - manifest = load_json(manifest_path) - if not basic_manifest_valid(manifest): - raise ValueError("required manifest fields are missing or invalid") - except (OSError, ValueError, json.JSONDecodeError) as error: - report.add("GOV-MANIFEST-001", f"Governance manifest is invalid: {error}", "Restore a manifest conforming to the pinned governance schema.", [args.manifest]) - manifest = None + return None + try: + manifest = load_json(manifest_path) + if not basic_manifest_valid(manifest): + raise ValueError("required manifest fields are missing or invalid") + except (OSError, ValueError, json.JSONDecodeError) as error: + report.add("GOV-MANIFEST-001", f"Governance manifest is invalid: {error}", "Restore a manifest conforming to the pinned governance schema.", [raw_path]) + return None + return manifest - if manifest is not None: - try: - lock_path = safe_repo_path(root, args.lock) if args.lock else None - except ValueError as error: - report.add("GOV-SYNC-001", str(error), "Use a repository-relative governance lock path.", [args.lock]) - lock_path = None - try: - profiles_path = safe_repo_path(root, args.stack_profiles) if args.stack_profiles else None - except ValueError as error: - report.add("GOV-MANIFEST-001", str(error), "Use a repository-relative stack-profile path.", [args.stack_profiles]) - profiles_path = None - try: - changed = changed_paths(root, args.base, args.head, args.changed_file) - except (RuntimeError, ValueError) as error: - report.add( - "GOV-DIFF-001", str(error), - "Use repository-relative changed paths and fetch the complete base/head history before retrying.", - evidence={"base": args.base, "head": args.head}, - ) - changed = [] - check_lock(root, lock_path, manifest, report) - check_required_files(root, manifest, report) - check_stacks(root, manifest, profiles_path, report) - directories = ticket_directories(root, manifest["ticket"]) - check_ticket_content(root, directories, manifest["ticket"], report) - records = load_ticket_records(directories, manifest["ticket"]) - check_coordination(root, manifest, records, changed, report) - check_changed_content(root, changed, args.actor, args.trusted_human_change, report) - check_change_gate( - root, manifest, records, changed, args.base, args.head, args.approval_source, - args.approved_ticket, args.enforce_approval, report, + +def optional_repo_path( + root: Path, + raw_path: str | None, + code: str, + label: str, + report: Report, +) -> Path | None: + if not raw_path: + return None + try: + return safe_repo_path(root, raw_path) + except ValueError as error: + report.add(code, str(error), f"Use a repository-relative {label} path.", [raw_path]) + return None + + +def resolve_changed_paths(args: argparse.Namespace, root: Path, report: Report) -> list[str]: + try: + return changed_paths(root, args.base, args.head, args.changed_file) + except (RuntimeError, ValueError) as error: + report.add( + "GOV-DIFF-001", str(error), + "Use repository-relative changed paths and fetch the complete base/head history before retrying.", + evidence={"base": args.base, "head": args.head}, ) + return [] - output_path = None - if args.output: - try: - output_path = safe_repo_path(root, args.output) - except ValueError as error: - report.add("GOV-PATH-001", str(error), "Use a repository-relative report output path.", [args.output]) - payload = report.payload() - if args.format == "json": - output = json.dumps(payload, indent=2, sort_keys=True) + "\n" - elif args.format == "sarif": - output = json.dumps(sarif(payload), indent=2, sort_keys=True) + "\n" - else: - output = render_text(payload) - if output_path is not None: - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(output, encoding="utf-8") - else: + +def run_governance_checks( + args: argparse.Namespace, + root: Path, + manifest: dict[str, Any], + report: Report, +) -> str | None: + lock_path = optional_repo_path(root, args.lock, "GOV-SYNC-001", "governance lock", report) + profiles_path = optional_repo_path(root, args.stack_profiles, "GOV-MANIFEST-001", "stack-profile", report) + changed = resolve_changed_paths(args, root, report) + check_lock(root, lock_path, manifest, report) + check_required_files(root, manifest, report) + check_stacks(root, manifest, profiles_path, report) + directories = ticket_directories(root, manifest["ticket"]) + check_ticket_content(root, directories, manifest["ticket"], report) + records = load_ticket_records(directories, manifest["ticket"]) + check_coordination(root, manifest, records, changed, report) + check_changed_content(root, changed, args.actor, args.trusted_human_change, report) + return check_change_gate( + root, manifest, records, changed, args.base, args.head, args.approval_source, + args.approved_ticket, args.approval_evidence, args.expected_repository, + args.expected_pull_request, args.expected_head, args.enforce_approval, + args.elapsed_minutes, report, + ) + + +def formatted_report(payload: dict[str, Any], output_format: str) -> str: + if output_format == "json": + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + if output_format == "sarif": + return json.dumps(sarif(payload), indent=2, sort_keys=True) + "\n" + return render_text(payload) + + +def write_report(output_path: Path | None, output: str) -> None: + if output_path is None: sys.stdout.write(output) + return + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(output, encoding="utf-8") + + +def write_resolved_ticket( + root: Path, + raw_path: str | None, + selected_ticket: str | None, + report: Report, +) -> None: + if not raw_path or not selected_ticket or report.errors: + return + path = Path(raw_path).expanduser().resolve() + if path.is_relative_to(root): + report.add( + "GOV-PATH-001", "Resolved ticket output must be outside the repository checkout.", + "Write ephemeral approval context to runner.temp or another protected directory.", + [rel(root, path)], + ) + return + try: + path.write_text(f"{selected_ticket}\n", encoding="utf-8") + except OSError as error: + report.add( + "GOV-PATH-001", f"Could not write resolved ticket output: {error}", + "Use a writable protected directory outside the checkout.", + ) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + root = Path(args.root).resolve() + report = Report(root) + manifest = load_manifest(root, args.manifest, report) + selected_ticket: str | None = None + + if manifest is not None: + selected_ticket = run_governance_checks(args, root, manifest, report) + write_resolved_ticket(root, args.resolved_ticket_output, selected_ticket, report) + output_path = optional_repo_path(root, args.output, "GOV-PATH-001", "report output", report) + payload = report.payload() + write_report(output_path, formatted_report(payload, args.format)) return 0 if report.errors == 0 else 1 diff --git a/.governance/intent.schema.json b/.governance/intent.schema.json index 83c667b..21aa712 100644 --- a/.governance/intent.schema.json +++ b/.governance/intent.schema.json @@ -20,9 +20,145 @@ { "type": "null" }, { "type": "string", "pattern": "^ticket-[0-9]{3}$" } ] + }, + "delivery": { + "type": "object", + "additionalProperties": false, + "required": [ + "acceptedBaseSha", + "targetBranch", + "outcome", + "nonGoals", + "complexity", + "estimatedMinutes", + "budgets", + "architecture", + "runtimeDependencies", + "validation" + ], + "properties": { + "acceptedBaseSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "targetBranch": { "$ref": "#/$defs/branch" }, + "outcome": { "type": "string", "minLength": 1 }, + "nonGoals": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + }, + "complexity": { "enum": ["XS", "S"] }, + "estimatedMinutes": { "type": "integer", "minimum": 1, "maximum": 30 }, + "budgets": { + "type": "object", + "additionalProperties": false, + "required": [ + "maxImplementationFiles", + "maxAffectedComponents", + "maxPublicInterfaceChanges", + "maxRuntimeDependencies" + ], + "properties": { + "maxImplementationFiles": { "type": "integer", "minimum": 1 }, + "maxAffectedComponents": { "type": "integer", "minimum": 1 }, + "maxPublicInterfaceChanges": { "type": "integer", "minimum": 0 }, + "maxRuntimeDependencies": { "type": "integer", "minimum": 0 } + } + }, + "architecture": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "decision", + "components", + "responsibilityChanges", + "interfaceChanges", + "dataChanges", + "ui", + "rollback" + ], + "properties": { + "status": { "const": "accepted" }, + "decision": { "type": "string", "minLength": 1 }, + "components": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "paths"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "paths": { + "type": "array", + "items": { "$ref": "#/$defs/glob" }, + "minItems": 1, + "uniqueItems": true + } + } + } + }, + "responsibilityChanges": { "type": "boolean" }, + "interfaceChanges": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "dataChanges": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "ui": { + "type": "object", + "additionalProperties": false, + "required": ["impact", "states", "evidence"], + "properties": { + "impact": { "enum": ["none", "single-state", "multi-state"] }, + "states": { + "type": "array", + "items": { "enum": ["loading", "empty", "error", "success"] }, + "uniqueItems": true + }, + "evidence": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + } + }, + "rollback": { "type": "string", "minLength": 1 } + } + }, + "runtimeDependencies": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "validation": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["criterion", "commands", "evidence"], + "properties": { + "criterion": { "type": "string", "pattern": "^AC-[0-9]+$" }, + "commands": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + }, + "evidence": { "type": "string", "minLength": 1 } + } + } + } + } } }, "$defs": { - "glob": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" } + "glob": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" }, + "branch": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:\\.\\.|//|@\\{|[~^:?*\\[\\\\])).+$" } } } diff --git a/.governance/manifest.json b/.governance/manifest.json index e4a8f03..13b507f 100644 --- a/.governance/manifest.json +++ b/.governance/manifest.json @@ -3,7 +3,7 @@ "schema": "new-project.governance/v2", "standard": { "id": "wellmanifest/new-project", - "version": "0.9.0" + "version": "0.11.0" }, "requiredFiles": [ "README.md", diff --git a/.governance/manifest.lock.json b/.governance/manifest.lock.json index cc8d3f6..640ff39 100644 --- a/.governance/manifest.lock.json +++ b/.governance/manifest.lock.json @@ -1,13 +1,17 @@ { "managedFiles": { - ".governance/diagnostics.json": "85f2d054803ed8a347bbf3ca8e0fd153de1e747c3e0e48e9986bb5fa06362251", - ".governance/governance_check.py": "4304976cb0b16b3cc6a676aa41425211647babefc54cf135ed8d11e1f4c3e4db", - ".governance/intent.schema.json": "b2dc37ee348ca33e2f0d33515dd79c2403ac8afb504257cdebb65edf472a2637", + ".governance/approval-evidence.schema.json": "488dee5a4bfbf221206acc45947fce5283eb5e80614ec0ef478b5d618cc4eb83", + ".governance/diagnostics.json": "19a90eee30bb84827379c731bb7c688f39d3124a1408226116dec1631ee67a5a", + ".governance/governance_check.py": "aabd710d8c273faa59e6fd26b6d5d9c04f55b084cb9fd3df3c26c5ba0cbc56ea", + ".governance/intent.schema.json": "d618a2c7ef4b4a24fbf95d516bf8c8231836e7752b28f0450cc96f02f07b6780", ".governance/lock.schema.json": "fc6f1143ef713c993b61270dd2d7545a52cb0b8501aadb188e6d0152a208b207", - ".governance/manifest.json": "f5afc8093ebce57cff836f66a37094d402fcb7306fda97fde527ad88a280f829", - ".governance/manifest.schema.json": "6504e451643c603b02e93e2c52ec72a6b3a6d72c556f7b2cdfb35856541021d3", + ".governance/manifest.json": "4ca33d114f24ad645cc234c1f10188d1f38b9c3106aabd480c4b6c4f79acb858", + ".governance/manifest.schema.json": "d48f258e3397ac2d8c5010e5ccdeef9eaf87b0b015d56798d11d5916c5f70a18", + ".governance/package-manifest.json": "d9d7052c51f0df5d92260b1f97fb0f7e1f77bd9b890a095daea5a1ea625b4ab3", ".governance/stack-profiles.json": "6fa3f8f44e50cfd0539413a85092817d3fab4e82fe376405f1e443120724dff2", - "AGENTS.md": "204fb955fc54bb976008f213e526891da5717fdc76c3f7dfb5be1d885bc0df78", + ".governance/work-classification.dsl.json": "3a947c41938c0b8ef1717957f313ff9248764252735182de30b1f2d6878748b6", + ".governance/work-classification.schema.json": "f5c2b518238543589e4f8d3805cc6455e19d6919643644aaeae034abf472a467", + "AGENTS.md": "e3928661a4e3ede5e9e60d8c0b28d4f6bb08057994a290da10197170f38a108d", "project.bat": "d707e014dba4d66e64ff6d4e212ceeedb8b65ec96ce76677cc3fc025875f45f6", "project.sh": "90f82d9f0feea9bde34dca3e1c657604a65f3bfc938709e3cf69682f07dd1bc1", "project/governance-check.bat": "7207bc499483d7a7a1ab2c230ad288c2484cdf02f3a773ba69f4b760b67a3388", @@ -20,7 +24,7 @@ "id": "wellmanifest/new-project", "publicationStatus": "published", "sourceRepository": "wellmanifest/new-project", - "sourceRevision": "c0bb63e7fc889934140c96b1625f3ab232122baf", - "version": "0.9.0" + "sourceRevision": "cc9b04673bbd85cb4e35fb683d288ef34be1485f", + "version": "0.11.0" } } diff --git a/.governance/manifest.schema.json b/.governance/manifest.schema.json index ae3273e..efb92fb 100644 --- a/.governance/manifest.schema.json +++ b/.governance/manifest.schema.json @@ -21,10 +21,35 @@ "governancePaths": { "type": "array", "items": { "$ref": "#/$defs/glob" }, "uniqueItems": true }, "trustedApprovalSources": { "type": "array", - "items": { "enum": ["github-review", "signed-attestation"] }, + "items": { "enum": ["github-review", "github-app-review", "signed-attestation"] }, "minItems": 1, "uniqueItems": true }, + "approvalEvidence": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "requiredBindings", "reviewVerificationMethod", "signedAttestationPredicateType"], + "properties": { + "schema": { "const": "new-project.approval-evidence/v1" }, + "requiredBindings": { + "type": "array", + "prefixItems": [ + { "const": "repository" }, + { "const": "pullRequest" }, + { "const": "headSha" }, + { "const": "ticket" }, + { "const": "actor" } + ], + "items": false, + "minItems": 5, + "maxItems": 5 + }, + "reviewVerificationMethod": { "const": "github-api-allowlist" }, + "signedAttestationPredicateType": { + "const": "https://wellmanifest.dev/attestations/validator/v1" + } + } + }, "ticket": { "type": "object", "additionalProperties": false, @@ -83,6 +108,54 @@ } } }, + "delivery": { + "type": "object", + "additionalProperties": false, + "required": [ + "requiredForImplementation", + "maxActiveMinutes", + "checkpointMinutes", + "allowedComplexityClasses", + "maxImplementationFiles", + "maxAffectedComponents", + "maxPublicInterfaceChanges", + "maxRuntimeDependencies", + "targetBranches", + "publicInterfacePaths", + "dependencyManifestPaths" + ], + "properties": { + "requiredForImplementation": { "type": "boolean" }, + "maxActiveMinutes": { "type": "integer", "minimum": 1, "maximum": 30 }, + "checkpointMinutes": { "type": "integer", "minimum": 1, "maximum": 29 }, + "allowedComplexityClasses": { + "type": "array", + "items": { "enum": ["XS", "S"] }, + "minItems": 1, + "uniqueItems": true + }, + "maxImplementationFiles": { "type": "integer", "minimum": 1 }, + "maxAffectedComponents": { "type": "integer", "minimum": 1 }, + "maxPublicInterfaceChanges": { "type": "integer", "minimum": 0 }, + "maxRuntimeDependencies": { "type": "integer", "minimum": 0 }, + "targetBranches": { + "type": "array", + "items": { "$ref": "#/$defs/branch" }, + "minItems": 1, + "uniqueItems": true + }, + "publicInterfacePaths": { + "type": "array", + "items": { "$ref": "#/$defs/glob" }, + "uniqueItems": true + }, + "dependencyManifestPaths": { + "type": "array", + "items": { "$ref": "#/$defs/path" }, + "uniqueItems": true + } + } + }, "stacks": { "type": "array", "items": { "enum": ["node", "python", "go", "rust", "java", "docker", "frontend", "terraform", "kubernetes"] }, @@ -92,6 +165,7 @@ }, "$defs": { "path": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" }, - "glob": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" } + "glob": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" }, + "branch": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:\\.\\.|//|@\\{|[~^:?*\\[\\\\])).+$" } } } diff --git a/.governance/package-manifest.json b/.governance/package-manifest.json new file mode 100644 index 0000000..156fbe6 --- /dev/null +++ b/.governance/package-manifest.json @@ -0,0 +1,23 @@ +{ + "schema": "new-project.package-manifest/v1", + "files": [ + { "source": "template/files/AGENTS.template.md", "target": "AGENTS.md", "strategy": "managed", "executable": false }, + { "source": "project.sh", "target": "project.sh", "strategy": "managed", "executable": true }, + { "source": "project.bat", "target": "project.bat", "strategy": "managed", "executable": false }, + { "source": "governance/approval-evidence.schema.json", "target": ".governance/approval-evidence.schema.json", "strategy": "managed", "executable": false }, + { "source": "governance/diagnostics.json", "target": ".governance/diagnostics.json", "strategy": "managed", "executable": false }, + { "source": "governance/intent.schema.json", "target": ".governance/intent.schema.json", "strategy": "managed", "executable": false }, + { "source": "governance/lock.schema.json", "target": ".governance/lock.schema.json", "strategy": "managed", "executable": false }, + { "source": "governance/manifest.schema.json", "target": ".governance/manifest.schema.json", "strategy": "managed", "executable": false }, + { "source": "governance/package-manifest.json", "target": ".governance/package-manifest.json", "strategy": "managed", "executable": false }, + { "source": "governance/stack-profiles.json", "target": ".governance/stack-profiles.json", "strategy": "managed", "executable": false }, + { "source": "governance/work-classification.dsl.json", "target": ".governance/work-classification.dsl.json", "strategy": "managed", "executable": false }, + { "source": "governance/work-classification.schema.json", "target": ".governance/work-classification.schema.json", "strategy": "managed", "executable": false }, + { "source": "scripts/governance_check.py", "target": ".governance/governance_check.py", "strategy": "managed", "executable": false }, + { "source": "project/governance-check.sh", "target": "project/governance-check.sh", "strategy": "managed", "executable": true }, + { "source": "project/governance-check.bat", "target": "project/governance-check.bat", "strategy": "managed", "executable": false }, + { "source": "project/new-ticket.sh", "target": "project/new-ticket.sh", "strategy": "managed", "executable": true }, + { "source": "project/readme.sh", "target": "project/readme.sh", "strategy": "managed", "executable": true }, + { "source": "governance/manifest.default.json", "target": ".governance/manifest.json", "strategy": "seed", "executable": false } + ] +} diff --git a/.governance/work-classification.dsl.json b/.governance/work-classification.dsl.json new file mode 100644 index 0000000..80c09f8 --- /dev/null +++ b/.governance/work-classification.dsl.json @@ -0,0 +1,120 @@ +{ + "$schema": "./work-classification.schema.json", + "schema": "new-project.work-classification/v1", + "dimensions": { + "kind": ["BUG", "FEATURE", "SERVICE"], + "priority": ["P0", "P1", "P2", "P3"], + "origin": ["regression", "requested", "health"] + }, + "ordering": { + "precedence": ["dependencies", "kind", "priority", "stableId"], + "kindOrder": ["BUG", "FEATURE", "SERVICE"], + "priorityOrder": ["P0", "P1", "P2", "P3"], + "dependencyPolicy": "topological-before-ranking", + "stableIdPolicy": "lexicographic" + }, + "priorityDerivation": { + "impact": { + "critical": "P0", + "high": "P1", + "medium": "P2", + "low": "P3" + }, + "declaredPolicy": "require-valid-priority", + "serviceDefault": "P2" + }, + "evaluation": { + "mode": "first-match", + "unmatchedPolicy": "reject", + "llmRole": "advisory-only" + }, + "rules": [ + { + "id": "W-CLASS-001", + "when": { + "signal": "defect", + "impact": "outage-or-security" + }, + "assign": { + "kind": "BUG", + "origin": "regression" + }, + "prioritySource": "impact" + }, + { + "id": "W-CLASS-002", + "when": { + "signal": "cyclomatic-complexity", + "baseline": "measured", + "delta": "increased" + }, + "assign": { + "kind": "BUG", + "origin": "regression" + }, + "prioritySource": "impact" + }, + { + "id": "W-CLASS-003", + "when": { + "signal": "cyclomatic-complexity", + "baseline": "measured", + "threshold": "crossed" + }, + "assign": { + "kind": "BUG", + "origin": "regression" + }, + "prioritySource": "impact" + }, + { + "id": "W-CLASS-004", + "when": { + "signal": "cyclomatic-complexity", + "baseline": "pre-existing", + "delta": "not-increased" + }, + "assign": { + "kind": "SERVICE", + "origin": "health" + }, + "prioritySource": "service-default" + }, + { + "id": "W-CLASS-005", + "when": { + "signal": "work-request", + "request": "new-behavior" + }, + "assign": { + "kind": "FEATURE", + "origin": "requested" + }, + "prioritySource": "declared" + }, + { + "id": "W-CLASS-006", + "when": { + "signal": "work-request", + "request": "maintenance" + }, + "assign": { + "kind": "SERVICE", + "origin": "health" + }, + "prioritySource": "service-default" + }, + { + "id": "W-CLASS-007", + "when": { + "signal": "defect", + "impact": "functional" + }, + "assign": { + "kind": "BUG", + "origin": "regression" + }, + "prioritySource": "impact" + } + ] +} diff --git a/.governance/work-classification.schema.json b/.governance/work-classification.schema.json new file mode 100644 index 0000000..6c19271 --- /dev/null +++ b/.governance/work-classification.schema.json @@ -0,0 +1,180 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/work-classification.schema.json", + "title": "new-project work classification DSL", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema", "dimensions", "ordering", "priorityDerivation", "evaluation", "rules"], + "properties": { + "$schema": {"const": "./work-classification.schema.json"}, + "schema": {"const": "new-project.work-classification/v1"}, + "dimensions": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "priority", "origin"], + "properties": { + "kind": { + "type": "array", + "prefixItems": [{"const": "BUG"}, {"const": "FEATURE"}, {"const": "SERVICE"}], + "items": false, + "minItems": 3, + "maxItems": 3, + "uniqueItems": true + }, + "priority": { + "type": "array", + "prefixItems": [{"const": "P0"}, {"const": "P1"}, {"const": "P2"}, {"const": "P3"}], + "items": false, + "minItems": 4, + "maxItems": 4, + "uniqueItems": true + }, + "origin": { + "type": "array", + "prefixItems": [{"const": "regression"}, {"const": "requested"}, {"const": "health"}], + "items": false, + "minItems": 3, + "maxItems": 3, + "uniqueItems": true + } + } + }, + "ordering": { + "type": "object", + "additionalProperties": false, + "required": ["precedence", "kindOrder", "priorityOrder", "dependencyPolicy", "stableIdPolicy"], + "properties": { + "precedence": { + "type": "array", + "prefixItems": [ + {"const": "dependencies"}, + {"const": "kind"}, + {"const": "priority"}, + {"const": "stableId"} + ], + "items": false, + "minItems": 4, + "maxItems": 4, + "uniqueItems": true + }, + "kindOrder": { + "type": "array", + "prefixItems": [{"const": "BUG"}, {"const": "FEATURE"}, {"const": "SERVICE"}], + "items": false, + "minItems": 3, + "maxItems": 3, + "uniqueItems": true + }, + "priorityOrder": { + "type": "array", + "prefixItems": [{"const": "P0"}, {"const": "P1"}, {"const": "P2"}, {"const": "P3"}], + "items": false, + "minItems": 4, + "maxItems": 4, + "uniqueItems": true + }, + "dependencyPolicy": {"const": "topological-before-ranking"}, + "stableIdPolicy": {"const": "lexicographic"} + } + }, + "priorityDerivation": { + "type": "object", + "additionalProperties": false, + "required": ["impact", "declaredPolicy", "serviceDefault"], + "properties": { + "impact": { + "type": "object", + "additionalProperties": false, + "required": ["critical", "high", "medium", "low"], + "properties": { + "critical": {"const": "P0"}, + "high": {"const": "P1"}, + "medium": {"const": "P2"}, + "low": {"const": "P3"} + } + }, + "declaredPolicy": {"const": "require-valid-priority"}, + "serviceDefault": {"enum": ["P0", "P1", "P2", "P3"]} + } + }, + "evaluation": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "unmatchedPolicy", "llmRole"], + "properties": { + "mode": {"const": "first-match"}, + "unmatchedPolicy": {"const": "reject"}, + "llmRole": {"const": "advisory-only"} + } + }, + "rules": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "uniqueItems": true, + "items": {"$ref": "#/$defs/rule"} + } + }, + "$defs": { + "rule": { + "type": "object", + "additionalProperties": false, + "required": ["id", "when", "assign", "prioritySource"], + "properties": { + "id": {"type": "string", "pattern": "^W-CLASS-[0-9]{3}$"}, + "when": { + "type": "object", + "additionalProperties": false, + "required": ["signal"], + "properties": { + "signal": {"enum": ["defect", "cyclomatic-complexity", "work-request"]}, + "impact": {"enum": ["outage-or-security", "functional"]}, + "baseline": {"enum": ["measured", "pre-existing"]}, + "delta": {"enum": ["increased", "not-increased"]}, + "threshold": {"const": "crossed"}, + "request": {"enum": ["new-behavior", "maintenance"]} + }, + "allOf": [ + { + "if": { + "properties": {"signal": {"const": "defect"}}, + "required": ["signal"] + }, + "then": {"required": ["impact"]} + }, + { + "if": { + "properties": {"signal": {"const": "cyclomatic-complexity"}}, + "required": ["signal"] + }, + "then": { + "required": ["baseline"], + "anyOf": [ + {"required": ["delta"]}, + {"required": ["threshold"]} + ] + } + }, + { + "if": { + "properties": {"signal": {"const": "work-request"}}, + "required": ["signal"] + }, + "then": {"required": ["request"]} + } + ] + }, + "assign": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "origin"], + "properties": { + "kind": {"enum": ["BUG", "FEATURE", "SERVICE"]}, + "origin": {"enum": ["regression", "requested", "health"]} + } + }, + "prioritySource": {"enum": ["impact", "declared", "service-default"]} + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md index c8e248b..527cbbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,17 @@ Before any multi-step implementation, an agent must: and `BLOCKED` retain evidence without blocking another implementation; transition back to `IN_PROGRESS` before changing source or tests. 11. Treat GitHub review as trusted only when it targets the current HEAD and - the reviewer login is in the protected reusable-workflow - `trusted-reviewers` input. + either a `User` login is in protected `trusted-reviewers` or a `Bot` login + is in the separate protected `trusted-validator-apps` input. Never trust an + arbitrary Bot review. +12. Require merge approval evidence to bind repository, PR, current HEAD, + active ticket and actor. The protected resolver creates that evidence + outside the PR checkout; repository-authored evidence is untrusted. +13. A signed attestation is trusted only after a protected verifier validates + its signature, issuer, predicate type and subject bindings. +14. Validator-agent examples use + `LLM_MODEL_VALIDATOR=openrouter/z-ai/glm-5.2`; model findings stay advisory. Markdown approval is an audit note, not trusted merge authorization. Required -merge approval comes from the repository's protected review/ruleset boundary. +merge approval comes from the repository's protected review, attestation and +ruleset boundary. diff --git a/TODO.md b/TODO.md index 8822aa9..0ac7a86 100644 --- a/TODO.md +++ b/TODO.md @@ -14,14 +14,14 @@ delivery policy in Goal configuration and install the local pre-push guard. - [ ] Review [ticket-006](project/ticket-006/README.md): adopt immutable new-project 0.11.0 through Goal's local governance adapter before adding the - CC-to-kind runtime classifier. State: `PLAN / WAIT_FOR_APPROVAL`. + CC-to-kind runtime classifier. State: `IN_PROGRESS / VALIDATION`. - [ ] After governance bootstrap, execute the sequential phases defined in [the refactoring plan](docs/GOAL_KORU_SUBACTOR_REFACTORING_PLAN.md), with one narrowly scoped ticket active at a time. > Current workflow state: `ticket-001 DONE`; `ticket-002 DONE`; > `ticket-003 DONE`; `ticket-004 DONE`; `ticket-005 DONE`; -> `ticket-006 PLAN / WAIT_FOR_APPROVAL`. +> `ticket-006 IN_PROGRESS / VALIDATION`. > **Recently shipped (manual note):** `goal all [PATHS...]` monorepo sweep — > runs `goal -a` in every git repo with uncommitted changes under the given diff --git a/project/ticket-006/README.md b/project/ticket-006/README.md index 4e7cd62..f05f3ed 100644 --- a/project/ticket-006/README.md +++ b/project/ticket-006/README.md @@ -2,8 +2,8 @@ - **ID**: ticket-006 - **Owner**: unresolved:human -- **Status**: PLAN -- **Workflow state**: WAIT_FOR_APPROVAL +- **Status**: IN_PROGRESS +- **Workflow state**: VALIDATION - **Created**: 2026-08-05 ## Goal and scope @@ -20,12 +20,12 @@ application ticket after adoption. ## Acceptance criteria -- [ ] AC-01: Scope and immutable source SHA are approved by a human owner. -- [ ] AC-02: The target manifest preserves Goal ownership and declares 0.11.0. -- [ ] AC-03: Local Goal reports and applies only the reviewed immutable upgrade. -- [ ] AC-04: The resulting lock binds 0.11.0, published status, full SHA and +- [x] AC-01: Scope and immutable source SHA are approved by a human owner. +- [x] AC-02: The target manifest preserves Goal ownership and declares 0.11.0. +- [x] AC-03: Local Goal reports and applies only the reviewed immutable upgrade. +- [x] AC-04: The resulting lock binds 0.11.0, published status, full SHA and managed classification DSL/schema files. -- [ ] AC-05: Governance and focused Goal adoption tests pass; Docker remains +- [x] AC-05: Governance and focused Goal adoption tests pass; Docker remains optional as declared by the preserved target manifest. ## Participants @@ -42,3 +42,17 @@ application ticket after adoption. - Hosted CI already fails on unchanged `main@b84d40a` because `_validate_pfix_env` references an undefined `api_key` on Python 3.12/3.13. This pre-existing `SERVICE/health` bug is outside the governance-only scope. + +## Session authorization + +The user approved ticket-006 with the instruction to continue on 2026-08-05. +This authorizes implementation inside `intent.json`, not merge approval. + +## Validation evidence + +- Repeated adoption check: up to date at immutable new-project 0.11.0 SHA. +- `./project/governance-check.sh`: PASS with zero errors and warnings. +- Focused governance/adoption tests: 11 passed. +- Full local suite: 476 passed, 2 skipped and the single documented + current-main `api_key` NameError failed. +- `git diff --check`: PASS. diff --git a/project/ticket-006/ai-codex-logs.txt b/project/ticket-006/ai-codex-logs.txt index d5e2cca..0d2bce2 100644 --- a/project/ticket-006/ai-codex-logs.txt +++ b/project/ticket-006/ai-codex-logs.txt @@ -14,3 +14,12 @@ failure: NameError name 'api_key' is not defined in goal/project_bootstrap.py:_v Python 3.13 summary: 1 failed, 471 passed, 7 skipped unchanged main@b84d40a CI run 30986834239: FAILURE classification: pre-existing SERVICE/health debt outside ticket-006 scope +2026-08-05 APPROVAL: user requested continuation and approved ticket-006 +transition: PLAN / WAIT_FOR_APPROVAL -> IN_PROGRESS / EDIT +2026-08-05 goal governance adopt --upgrade: PASS, new-project 0.11.0 at cc9b04673bbd85cb4e35fb683d288ef34be1485f +2026-08-05 goal governance adopt --check: PASS, up-to-date +2026-08-05 ./project/governance-check.sh: GOV-PASS (0 errors, 0 warnings) +2026-08-05 focused governance/adoption tests: PASS (11 passed) +2026-08-05 full local suite: BASELINE FAILURE (476 passed, 2 skipped, 1 pre-existing NameError) +2026-08-05 git diff --check: PASS +transition: EDIT -> VALIDATION diff --git a/project/ticket-006/ai-codex.md b/project/ticket-006/ai-codex.md index 48c79be..4418642 100644 --- a/project/ticket-006/ai-codex.md +++ b/project/ticket-006/ai-codex.md @@ -25,11 +25,15 @@ current 0.9.0 target manifest until that declaration is reviewed. ## Actual changes -- None; waiting for approval. +- User approval received; ticket transitioned to `IN_PROGRESS / EDIT`. +- Goal adopted new-project 0.11.0 at the immutable release SHA and then reported + the target up to date on a repeated check. +- The managed package now includes the approval-evidence schema, package + manifest, canonical classification DSL/schema, current governance checker + and agent authority rules. ## Blockers -- Human approval is required before implementation. - Full hosted CI requires a separate application ticket for the pre-existing environment-discovery defect; ticket-006 does not broaden into runtime code. @@ -42,3 +46,5 @@ current 0.9.0 target manifest until that declaration is reviewed. - Draft PR CI reproduces the current-main failure in `tests/test_project_bootstrap.py`: `NameError: api_key` from `goal/project_bootstrap.py`; 471 tests pass and seven skip on Python 3.13. +- Local focused governance tests pass 11/11; the full local run passes 476 with + two skips and reproduces only the already documented `api_key` NameError. diff --git a/project/ticket-006/changelog.md b/project/ticket-006/changelog.md index 58d12b3..c88523d 100644 --- a/project/ticket-006/changelog.md +++ b/project/ticket-006/changelog.md @@ -8,3 +8,6 @@ - Kept CC-to-kind classification outside this governance-only ticket. - Recorded the current-main `api_key` NameError as separate SERVICE debt rather than expanding the adoption ticket into application repair. +- Adopted the complete immutable 0.11.0 governance package and canonical + classification contract through Goal's own adapter. +- Passed the deterministic gate and all 11 focused adoption/delivery tests. From f3a8327bfaf780b0fe2e165da968ef2e7524983c Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 5 Aug 2026 12:07:01 +0200 Subject: [PATCH 4/4] docs(ticket-006): refresh validation after baseline repair Co-authored-by: Koru Agent --- project/ticket-006/README.md | 12 ++++++------ project/ticket-006/ai-codex-logs.txt | 4 ++++ project/ticket-006/ai-codex.md | 8 ++++---- project/ticket-006/changelog.md | 2 ++ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/project/ticket-006/README.md b/project/ticket-006/README.md index f05f3ed..2315c2c 100644 --- a/project/ticket-006/README.md +++ b/project/ticket-006/README.md @@ -39,9 +39,9 @@ application ticket after adoption. ticket uses the repository's verified `.venv/bin/goal` entry point. - Cross-version adoption fails closed until the target manifest version is explicitly reviewed and advanced. -- Hosted CI already fails on unchanged `main@b84d40a` because - `_validate_pfix_env` references an undefined `api_key` on Python 3.12/3.13. - This pre-existing `SERVICE/health` bug is outside the governance-only scope. +- The pre-existing `_validate_pfix_env` failure was repaired independently by + ticket-007 before this branch was refreshed from `main`; this ticket does not + contain that application change in its own diff. ## Session authorization @@ -52,7 +52,7 @@ This authorizes implementation inside `intent.json`, not merge approval. - Repeated adoption check: up to date at immutable new-project 0.11.0 SHA. - `./project/governance-check.sh`: PASS with zero errors and warnings. -- Focused governance/adoption tests: 11 passed. -- Full local suite: 476 passed, 2 skipped and the single documented - current-main `api_key` NameError failed. +- Focused governance/adoption and bootstrap tests: 76 passed. +- Full local suite after merging the ticket-007 baseline: 477 passed, 2 skipped, + 0 failed. - `git diff --check`: PASS. diff --git a/project/ticket-006/ai-codex-logs.txt b/project/ticket-006/ai-codex-logs.txt index 0d2bce2..2d93747 100644 --- a/project/ticket-006/ai-codex-logs.txt +++ b/project/ticket-006/ai-codex-logs.txt @@ -23,3 +23,7 @@ transition: PLAN / WAIT_FOR_APPROVAL -> IN_PROGRESS / EDIT 2026-08-05 full local suite: BASELINE FAILURE (476 passed, 2 skipped, 1 pre-existing NameError) 2026-08-05 git diff --check: PASS transition: EDIT -> VALIDATION +2026-08-05 refreshed from main after ticket-007 DONE +2026-08-05 governance adoption check: PASS, immutable 0.11.0 up to date +2026-08-05 focused governance/bootstrap tests: PASS (76 passed) +2026-08-05 full pytest after baseline repair: PASS (477 passed, 2 skipped) diff --git a/project/ticket-006/ai-codex.md b/project/ticket-006/ai-codex.md index 4418642..0fdca4e 100644 --- a/project/ticket-006/ai-codex.md +++ b/project/ticket-006/ai-codex.md @@ -34,8 +34,8 @@ current 0.9.0 target manifest until that declaration is reviewed. ## Blockers -- Full hosted CI requires a separate application ticket for the pre-existing - environment-discovery defect; ticket-006 does not broaden into runtime code. +- None. Ticket-007 repaired the independent baseline defect and was merged + before this adoption branch was refreshed. ## Preflight evidence @@ -46,5 +46,5 @@ current 0.9.0 target manifest until that declaration is reviewed. - Draft PR CI reproduces the current-main failure in `tests/test_project_bootstrap.py`: `NameError: api_key` from `goal/project_bootstrap.py`; 471 tests pass and seven skip on Python 3.13. -- Local focused governance tests pass 11/11; the full local run passes 476 with - two skips and reproduces only the already documented `api_key` NameError. +- After refreshing from `main`, local governance/adoption/bootstrap tests pass + 76/76 and the complete suite passes 477 with two skips and zero failures. diff --git a/project/ticket-006/changelog.md b/project/ticket-006/changelog.md index c88523d..3bb59ae 100644 --- a/project/ticket-006/changelog.md +++ b/project/ticket-006/changelog.md @@ -11,3 +11,5 @@ - Adopted the complete immutable 0.11.0 governance package and canonical classification contract through Goal's own adapter. - Passed the deterministic gate and all 11 focused adoption/delivery tests. +- Refreshed from `main` after ticket-007 and passed 76 focused plus 477 full + tests with two skips and zero failures.