diff --git a/.aider.conf.yml b/.aider.conf.yml new file mode 100644 index 0000000..f133379 --- /dev/null +++ b/.aider.conf.yml @@ -0,0 +1,22 @@ +# +# Managed standard sources: local .governance/manifest.json, +# .governance/manifest.lock.json and .governance/package-manifest.json are +# authoritative. Remote links are navigation only and are never fetched at runtime. +# Canonical instructions: https://github.com/wellmanifest/new-project/blob/main/template/files/AGENTS.template.md +# Host contract: https://github.com/wellmanifest/new-project/blob/main/governance/agent-hosts.json +# Immutable adoption/updater: https://github.com/wellmanifest/new-project/blob/main/scripts/create_adoption_lock.py +# +# wellmanifest/new-project — fail-closed contract for aider. +# aider loads these files into every session, so the same rules apply here as +# in Cursor, Claude Code or Gemini. The pre-commit hook enforces them. +read: + - AGENTS.md + - CLAUDE.md + +# Never let the tool create commits the governance hook has not seen. +auto-commits: false +attribute-commit-message-author: true + +# Bounded session controls: respect the ticket's maxActiveMinutes, create a +# checkpoint before a context or tool boundary, and leave a handoff then stop +# after a deterministic failure instead of retrying indefinitely. diff --git a/.cursor/rules/new-project-standard.mdc b/.cursor/rules/new-project-standard.mdc new file mode 100644 index 0000000..8298ad6 --- /dev/null +++ b/.cursor/rules/new-project-standard.mdc @@ -0,0 +1,36 @@ +--- +description: wellmanifest/new-project fail-closed ticket contract for every Cursor session +alwaysApply: true +--- + + +## Managed standard sources + +Local adoption files are authoritative. Remote links are navigation only and +must not be fetched or executed at runtime. + +- Local adoption manifest: [.governance/manifest.json](.governance/manifest.json) +- Local adoption lock: [.governance/manifest.lock.json](.governance/manifest.lock.json) +- Local package map: [.governance/package-manifest.json](.governance/package-manifest.json) +- Canonical instructions: [AGENTS template](https://github.com/wellmanifest/new-project/blob/main/template/files/AGENTS.template.md) +- Host contract: [agent-hosts.json](https://github.com/wellmanifest/new-project/blob/main/governance/agent-hosts.json) +- Immutable adoption/updater: [create_adoption_lock.py](https://github.com/wellmanifest/new-project/blob/main/scripts/create_adoption_lock.py) + + + +# new-project standard (host-agnostic) + +Before writing code in this repository: + +1. Read `AGENTS.md` and the active ticket. Reuse an `IN_PROGRESS` ticket only when workstream and scope match. +2. Otherwise run `./project/new-ticket.sh --title "..." --agent "..." --workstream "..."`. +3. Work in a dedicated worktree/branch whose name contains `ticket-NNN`. Do not commit on `main` or a dirty primary checkout. +4. Stay inside `intent.json` `allowedPaths`. Commercial SSOT / offer / brand facades need the integration workstream. +5. Run `./scripts/install-agent-hosts.sh` once per clone so `.githooks/pre-commit` is active. +6. Run `./project/governance-check.sh` before claiming done. + +The pre-commit hook rejects commits that are not bound to an `IN_PROGRESS` ticket. Do not invent ticket numbers. Do not ask the human to approve a merge; invoke validator-agent when publication needs trusted approval. + +Bounded session controls: respect the ticket's `maxActiveMinutes`, create a +`checkpoint` before a context or tool boundary, and leave a `handoff` then +`stop` after a deterministic failure instead of retrying indefinitely. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..962ca01 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Wellmanifest new-project standard git attributes +project/TICKETS.md merge=wellmanifest-ticket-index +TODO.md merge=wellmanifest-ticket-index diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..72dac25 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Git invokes this after the message is available and before creating a commit. +# Staged scope and active-ticket checks remain in the pre-commit hook. +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +if [[ -n "${NEW_PROJECT_SEED_REQUEST:-}" ]]; then + [[ $# == 1 ]] || exit 1 + python3 -B "$root/.governance/seed_baseline.py" hook-check --root "$root" \ + --request "$NEW_PROJECT_SEED_REQUEST" --message-file "$1" + exit $? +fi +policy_runner="$root/.governance/repository_policy.py" +[[ -f "$policy_runner" ]] || policy_runner="$root/scripts/repository_policy.py" +if [[ -f "$policy_runner" ]]; then + profile="$(PYTHONDONTWRITEBYTECODE=1 python3 "$policy_runner" --root "$root" --profile 2>/dev/null || true)" + case "$profile" in + local-audit|main-only-planfile|main-only-files) exit 0 ;; + esac +fi + +branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)" +if [[ ! "$branch" =~ ticket[-/]([0-9]{3,}) ]]; then + echo "GOV-AGENT-HOST-001: commit subject cannot bind branch '$branch' to ticket-NNN." >&2 + exit 1 +fi +ticket="ticket-${BASH_REMATCH[1]}" +if [[ $# != 1 || ! -f "$1" ]]; then + echo "GOV-AGENT-HOST-001: commit message file is unavailable." >&2 + exit 1 +fi +subject="" +IFS= read -r subject < "$1" || true +if ! printf '%s\n' "$subject" | grep -Eq -- "(^|[^[:alnum:]_-])${ticket}([^[:alnum:]_-]|$)"; then + echo "GOV-AGENT-HOST-001: commit subject must include the branch ticket '$ticket'." >&2 + echo " Example: fix(component): repair behavior ($ticket)" >&2 + exit 1 +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..be3ebb1 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Managed adopter hook: bind implementation to an active ticket. The payload +# is separate from the hub's live hook so later runtime composition does not +# mutate the standard source's own enforcement while it is executing. + +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)" + +# A seed request can authorize only the allocator's exact prepared root commit. +# Existing history, product files and an unprepared request fail closed here. +if [[ -n "${NEW_PROJECT_SEED_REQUEST:-}" ]]; then + python3 -B "$root/.governance/seed_baseline.py" hook-check --root "$root" \ + --request "$NEW_PROJECT_SEED_REQUEST" + exit $? +fi + +# Run before every profile/status success path. Identity is supplied by the +# session launcher; the hook must never infer it from the lease holder. +if [[ "$branch" =~ ^ticket[-/][0-9]{3,} ]]; then + writer_runner="$root/.governance/change_lease_check.py" + [[ -f "$writer_runner" ]] || writer_runner="$root/scripts/change_lease_check.py" + if [[ ! -f "$writer_runner" ]]; then + echo "GOV-CHANGE-LEASE-001: writer-lease checker is missing; restore the managed installation." >&2 + exit 1 + fi + python3 -B "$writer_runner" writer-check --root "$root" --operation commit --ref "refs/heads/$branch" +fi + +run_worktree_guard() { + local runner="$root/.governance/worktree_guard.py" + if [[ -f "$runner" ]]; then + python3 "$runner" --root "$root" --once + return + fi + + echo "worktree-guard: the managed pre-commit hook cannot find worktree_guard.py." >&2 + echo " Restore the managed package or reinstall the repository guard:" >&2 + echo " ./scripts/install-worktree-guard.sh --target $root --wire-hook" >&2 + return 1 +} + +run_local_standard_pin_check() { + local runner="$root/.governance/work_continuity.py" + if [[ ! -f "$runner" ]]; then + # A host-files-only bootstrap predates full standard adoption and has no + # local pin to check. Once either pin file exists, the runtime is required. + if [[ ! -e "$root/.subactor/manifest.json" && ! -e "$root/.governance/manifest.lock.json" ]]; then + return 0 + fi + echo "GOV-CONTINUITY-001: the managed pre-commit hook cannot validate the local standard pin." >&2 + echo " Restore the managed package with the explicit standard updater; the hook never fetches." >&2 + return 1 + fi + + # Commits read only staged local manifests and managed digests. Resolve a + # newer release explicitly in its adoption ticket, outside this boundary. + python3 "$runner" verify-pin --root "$root" --staged >/dev/null +} + +run_commit_guards() { + run_local_standard_pin_check + run_worktree_guard +} + +policy_runner="$root/.governance/repository_policy.py" +if [[ -f "$policy_runner" ]]; then + repository_profile="$(PYTHONDONTWRITEBYTECODE=1 python3 "$policy_runner" --root "$root" --profile 2>/dev/null || true)" + if [[ "$repository_profile" == "local-audit" ]]; then + PYTHONDONTWRITEBYTECODE=1 python3 "$policy_runner" --root "$root" --staged >/dev/null || true + exit 0 + fi + if [[ "$repository_profile" == "main-only-planfile" || "$repository_profile" == "main-only-files" ]]; then + if [[ -z "$branch" || "$branch" == "HEAD" ]]; then + echo "GOV-AGENT-HOST-001: detached HEAD is not allowed by the main-only delivery profile." >&2 + exit 1 + fi + if ! PYTHONDONTWRITEBYTECODE=1 python3 "$policy_runner" --root "$root" --staged; then + echo "GOV-AGENT-HOST-002: staged change rejected by the repository delivery profile." >&2 + exit 1 + fi + run_commit_guards + exit 0 + fi +fi + +if [[ -z "$branch" || "$branch" == "HEAD" ]]; then + echo "GOV-AGENT-HOST-001: detached HEAD is not bound to ticket-NNN." >&2 + exit 1 +fi + +if [[ ! "$branch" =~ ticket[-/]([0-9]{3,}) ]]; then + echo "GOV-AGENT-HOST-001: branch '$branch' is not bound to ticket-NNN." >&2 + echo " Allocate with ./project/new-ticket.sh and commit on a ticket branch." >&2 + exit 1 +fi + +ticket="ticket-${BASH_REMATCH[1]}" +readme_rel="project/$ticket/README.md" + +ticket_storage="$(git config --local --get new-project.ticketStorage 2>/dev/null || true)" +if [[ "${ticket_storage:-files}" == sqlite ]]; then + reader="$root/.governance/ticket_input.py" + [[ -f "$reader" ]] || reader="$root/scripts/ticket_input.py" + if ! staged_readme="$(PYTHONDONTWRITEBYTECODE=1 python3 "$reader" read --root "$root" --ticket "$ticket" --file README.md)"; then + echo "GOV-AGENT-HOST-002: active SQLite ticket content is unavailable." >&2 + exit 1 + fi +elif [[ "${ticket_storage:-files}" == files ]]; then + if ! staged_readme="$(git show ":$readme_rel" 2>/dev/null)"; then + echo "GOV-AGENT-HOST-002: $root/$readme_rel is missing from the staged snapshot." >&2 + echo " Allocate with ./project/new-ticket.sh; do not invent a ticket number." >&2 + exit 1 + fi +else + echo "GOV-AGENT-HOST-002: unknown ticket storage mode." >&2 + exit 1 +fi + +governance_only_transition() { + # SQLite transitions never require a Git carrier commit. + [[ "${ticket_storage:-files}" == files ]] || return 1 + if git diff --cached --quiet -- "$readme_rel"; then + return 1 + fi + if ! git diff --cached --quiet --diff-filter=DRC --; then + return 1 + fi + + while IFS= read -r -d '' path; do + case "$path" in + "project/$ticket/"*|TODO.md|project/TICKETS.md|config/artifact-registry.json) ;; + *) return 1 ;; + esac + done < <(git diff --cached --name-only -z --diff-filter=AM --) +} + +if grep -Eiq '^-[[:space:]]+\*\*Status\*\*:[[:space:]]*IN_PROGRESS([[:space:]]|$)' <<<"$staged_readme"; then + head_readme="$(git show "HEAD:$readme_rel" 2>/dev/null || true)" + if grep -Eiq '^-[[:space:]]+\*\*Status\*\*:[[:space:]]*(BACKLOG|PLAN|BLOCKED)([[:space:]]|$)' <<<"$head_readme" \ + && governance_only_transition; then + run_commit_guards + exit 0 + fi + + material=false + while IFS= read -r -d '' path; do + case "$path" in + project/ticket-*/*|TODO.md|project/TICKETS.md|config/artifact-registry.json) ;; + *) material=true; break ;; + esac + done < <(git diff --cached --name-only -z --diff-filter=AM --) + if [[ "$material" != true ]]; then + echo "GOV-AGENT-HOST-007: staged change contains only ticket tracking carriers." >&2 + echo " Add a material deliverable, or emit an external no-change receipt without committing." >&2 + exit 1 + fi + run_commit_guards + exit 0 +fi + +if grep -Eiq '^-[[:space:]]+\*\*Status\*\*:[[:space:]]*(BACKLOG|PLAN|BLOCKED)([[:space:]]|$)' <<<"$staged_readme"; then + if governance_only_transition; then + run_commit_guards + exit 0 + fi + echo "GOV-AGENT-HOST-003: $ticket non-active transition is not governance-only." >&2 + echo " Stage the ticket README and only bounded governance evidence; keep implementation on IN_PROGRESS." >&2 + exit 1 +fi + +if grep -Eiq '^-[[:space:]]+\*\*Status\*\*:[[:space:]]*(DONE|CANCELLED)([[:space:]]|$)' <<<"$staged_readme"; then + echo "GOV-AGENT-HOST-003: repository terminal closure commits are forbidden." >&2 + echo " The protected delivery controller must emit the external terminal receipt without a repository write." >&2 + exit 1 +fi + +echo "GOV-AGENT-HOST-003: $ticket is neither IN_PROGRESS nor a valid staged non-active transition." >&2 +echo " Use BACKLOG, PLAN or BLOCKED only for bounded governance evidence; terminal state belongs to the protected external receipt." >&2 +exit 1 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..2608e71 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Git invokes this before a push. Delivery reaches the declared target branch only +# through a reviewed pull request merged by the validator; a direct push skips +# review, required checks and CI (GOV-PUSH-001). Repositories whose delivery +# profile is main-only or local-audit commit on the target branch by design. +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +allow_target=false +policy_runner="$root/.governance/repository_policy.py" +[[ -f "$policy_runner" ]] || policy_runner="$root/scripts/repository_policy.py" +if [[ -f "$policy_runner" ]]; then + profile="$(PYTHONDONTWRITEBYTECODE=1 python3 "$policy_runner" --root "$root" --profile 2>/dev/null || true)" + case "$profile" in + local-audit|main-only-planfile|main-only-files) allow_target=true ;; + esac +fi + +targets="$(PYTHONDONTWRITEBYTECODE=1 python3 - "$root" <<'PY' 2>/dev/null || true +import json, sys +from pathlib import Path +root = Path(sys.argv[1]) +for rel in (".governance/manifest.json", ".governance/manifest.base.json", "governance/manifest.hub.json"): + path = root / rel + if path.is_file(): + branches = json.loads(path.read_text(encoding="utf-8")).get("delivery", {}).get("targetBranches") + if isinstance(branches, list) and all(isinstance(b, str) and b for b in branches): + print("\n".join(branches)) + break +PY +)" +[[ -n "$targets" ]] || targets="main" + +refused=0 +while read -r local_ref local_sha remote_ref remote_sha; do + [[ -n "${remote_ref:-}" ]] || continue + for ref in "$local_ref" "$remote_ref"; do + if [[ "$ref" =~ ^refs/heads/ticket[-/][0-9]{3,} ]]; then + writer_runner="$root/.governance/change_lease_check.py" + [[ -f "$writer_runner" ]] || writer_runner="$root/scripts/change_lease_check.py" + if [[ ! -f "$writer_runner" ]]; then + echo "GOV-CHANGE-LEASE-001: writer-lease checker is missing; restore the managed installation." >&2 + refused=1 + elif ! python3 -B "$writer_runner" writer-check --root "$root" --operation push --ref "$ref"; then + refused=1 + fi + fi + done + [[ "$allow_target" == true ]] && continue + while IFS= read -r target; do + if [[ "$remote_ref" == "refs/heads/$target" ]]; then + echo "GOV-PUSH-001: direct push to '$target' refused ($local_ref -> $remote_ref)." >&2 + echo " Push the ticket branch and open a pull request; the validator merges it." >&2 + refused=1 + fi + done <<< "$targets" +done + +exit "$refused" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..1b5cdd1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,36 @@ +# GitHub Copilot instructions + + +## Managed standard sources + +The local adoption manifest, lock and package are authoritative. Remote links +are navigation only and must not be fetched or executed at runtime. + +- Local adoption manifest: [.governance/manifest.json](.governance/manifest.json) +- Local adoption lock: [.governance/manifest.lock.json](.governance/manifest.lock.json) +- Local package map: [.governance/package-manifest.json](.governance/package-manifest.json) +- Canonical instructions: [AGENTS template](https://github.com/wellmanifest/new-project/blob/main/template/files/AGENTS.template.md) +- Host contract: [agent-hosts.json](https://github.com/wellmanifest/new-project/blob/main/governance/agent-hosts.json) +- Immutable adoption/updater: [create_adoption_lock.py](https://github.com/wellmanifest/new-project/blob/main/scripts/create_adoption_lock.py) + + + +This repository follows the `wellmanifest/new-project` policy-as-code standard. +Same fail-closed contract as `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` and the Cursor +rule. Copilot Chat and the Copilot coding agent load this file automatically. + +1. Read `AGENTS.md` before proposing any change. +2. Allocate tickets only through `./project/new-ticket.sh`. Never copy a + `project/ticket-NNN` directory and never invent a ticket number. +3. Work on a branch or worktree whose name contains `ticket-NNN`. Never write on + `main` or a dirty primary checkout. +4. Stay inside that ticket's `intent.json` `allowedPaths`. +5. Run `./scripts/install-agent-hosts.sh` once per clone so `.githooks/pre-commit` + is active, then `./project/governance-check.sh` before claiming done. + +Suggestions that skip these steps are rejected by the pre-commit hook and by the +`governance / enforce` CI job. Markdown is not a substitute for either. + +Bounded session controls: respect the ticket's `maxActiveMinutes`, create a +`checkpoint` before a context or tool boundary, and leave a `handoff` then +`stop` after a deterministic failure instead of retrying indefinitely. diff --git a/.github/workflows/new-project-branch-hygiene.yml b/.github/workflows/new-project-branch-hygiene.yml new file mode 100644 index 0000000..8a010e9 --- /dev/null +++ b/.github/workflows/new-project-branch-hygiene.yml @@ -0,0 +1,27 @@ +name: new-project-branch-hygiene + +# GitHub's delete_branch_on_merge setting handles successfully merged branches. +# Closing an unmerged pull request does not authorize discarding its work. +# Explicit discard requires a separate owner decision and intent reconciliation. +on: + pull_request: + types: [closed] + +permissions: + contents: read + +jobs: + preserve-unmerged-head: + if: github.event.pull_request.merged == false + runs-on: ${{ vars.NEW_PROJECT_RUNNER_LABEL || 'ubuntu-latest' }} + steps: + - name: Report preserved pull request branch + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const ref = context.payload.pull_request.head.ref; + core.notice( + `Preserved ${JSON.stringify(ref)}. Closing a pull request does not ` + + 'authorize branch deletion. Reconcile remaining intent and obtain ' + + 'an explicit owner discard decision before a separate deletion.', + ); diff --git a/.github/workflows/new-project-governance.yml b/.github/workflows/new-project-governance.yml new file mode 100644 index 0000000..6a60d46 --- /dev/null +++ b/.github/workflows/new-project-governance.yml @@ -0,0 +1,266 @@ +name: new-project-governance + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +jobs: + remote-lifecycle: + name: governance / remote lifecycle + runs-on: ${{ vars.NEW_PROJECT_RUNNER_LABEL || 'ubuntu-latest' }} + steps: + - name: Check out governed repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Acquire GitHub branch lifecycle snapshot + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BRANCH_LIFECYCLE_SNAPSHOT: ${{ runner.temp }}/new-project-branch-lifecycle.json + with: + script: | + const fs = require('fs'); + const repository = await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + const settings = await github.graphql(` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { deleteBranchOnMerge } + } + `, {owner: context.repo.owner, repo: context.repo.repo}); + const branches = await github.paginate(github.rest.repos.listBranches, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + const pulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + const snapshot = { + schema: 'new-project.branch-lifecycle-snapshot/v1', + repository: `${context.repo.owner}/${context.repo.repo}`, + defaultBranch: repository.data.default_branch, + deleteBranchOnMerge: settings.repository.deleteBranchOnMerge, + branches: branches.map(branch => branch.name).sort(), + openPullRequests: pulls.map(pull => ({ + number: pull.number, + headRepository: pull.head.repo?.full_name ?? null, + headRef: pull.head.ref, + })).sort((left, right) => left.number - right.number), + }; + fs.writeFileSync( + process.env.BRANCH_LIFECYCLE_SNAPSHOT, + `${JSON.stringify(snapshot)}\n`, + {encoding: 'utf8', mode: 0o600}, + ); + - name: Validate remote branch lifecycle + shell: bash + env: + BRANCH_LIFECYCLE_SNAPSHOT: ${{ runner.temp }}/new-project-branch-lifecycle.json + HEAD_REF: ${{ github.head_ref }} + run: | + arguments=( + --snapshot "$BRANCH_LIFECYCLE_SNAPSHOT" + --expected-repository "$GITHUB_REPOSITORY" + --format text + ) + if [[ -n "${HEAD_REF:-}" ]]; then + arguments+=(--focus-branch "$HEAD_REF") + fi + python3 .governance/branch_lifecycle_check.py "${arguments[@]}" + + enforce: + name: governance / enforce + # A closed pull request has nothing left to gate; remote-lifecycle still runs. + if: github.event.action != 'closed' + runs-on: ${{ vars.NEW_PROJECT_RUNNER_LABEL || 'ubuntu-latest' }} + steps: + - name: Check out the exact revision under review + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Set up Python + if: runner.environment == 'github-hosted' + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - name: Require provisioned Python on self-hosted runner + if: runner.environment == 'self-hosted' + shell: bash + run: | + set -euo pipefail + command -v python3 >/dev/null + python3 -c 'import sys; assert sys.version_info >= (3, 11), sys.version' + - name: Install wellman governance runtime + shell: bash + env: + PROTECTED_RUNTIME_SHA: ${{ vars.NEW_PROJECT_RUNTIME_SHA }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_NAME: ${{ github.event_name }} + EVENT_SHA: ${{ github.sha }} + EVENT_REF: ${{ github.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GOVERNANCE_ROOT: . + run: | + set -euo pipefail + # Repository variable is protected configuration; candidate manifests are not. + runtime_ref="$(python3 -I - <<'PY_RUNTIME' + import json + import os + import re + import subprocess + from pathlib import PurePosixPath + + def reject(message): + raise SystemExit("Trusted governance runtime pin: " + message) + + def sha(value): + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{40}", value): + reject("a full lowercase commit SHA is required") + return value + + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + reject("duplicate lock key") + result[key] = value + return result + + protected = os.environ.get("PROTECTED_RUNTIME_SHA", "") + if protected: + print(sha(protected)) + else: + if os.environ.get("EVENT_NAME") == "pull_request": + branch = os.environ.get("DEFAULT_BRANCH", "") + if not branch or os.environ.get("BASE_REF") != branch: + reject("PR base must be the default branch or use a protected SHA") + base = sha(os.environ.get("BASE_SHA", "")) + else: + branch = os.environ.get("DEFAULT_BRANCH", "") + if not branch or os.environ.get("EVENT_REF") != "refs/heads/" + branch: + reject("non-PR runs require the default branch or a protected SHA") + base = sha(os.environ.get("EVENT_SHA", "")) + target = os.path.relpath(os.path.abspath(os.environ.get("GOVERNANCE_ROOT", ".")), os.getcwd()) + if ".." in PurePosixPath(target).parts or "\\" in target: + reject("target-root must stay inside the repository") + lock_path = str(PurePosixPath(target) / ".governance/manifest.lock.json") + observed = subprocess.run(["git", "show", base + ":" + lock_path], + capture_output=True, text=True) + if observed.returncode: + reject("trusted base lock unavailable; configure protected NEW_PROJECT_RUNTIME_SHA for initial adoption") + try: + lock = json.loads(observed.stdout, object_pairs_hook=unique) + standard = lock["standard"] + if (lock["schema"] != "new-project.lock/v1" + or standard["id"] != "wellmanifest/new-project" + or standard["sourceRepository"] != "wellmanifest/new-project" + or standard["publicationStatus"] != "published"): + reject("unsupported or unpublished trusted adoption") + print(sha(standard["sourceRevision"])) + except (ValueError, KeyError, TypeError): + reject("malformed trusted base lock") + PY_RUNTIME + )" + runtime_venv="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/wellman-governance.XXXXXX")" + python3 -I -m venv "$runtime_venv" + "$runtime_venv/bin/python" -I -m pip install --disable-pip-version-check "git+https://github.com/wellmanifest/new-project.git@${runtime_ref}#subdirectory=packages/wellman" + # Accept only a recognized console entrypoint from this isolated runtime. + runtime_command="$("$runtime_venv/bin/python" -I - <<'PY_RUNTIME_COMMAND' + import os + import sys + from importlib.metadata import distributions + from pathlib import Path + + if sys.prefix == sys.base_prefix: + raise SystemExit("Governance runtime requires its isolated environment") + found = [] + for name, command, module in ( + ("wellmanifest-governance", "wellman-governance", "wellmanifest_governance_runtime"), + ("wellman", "wellman", "wellman"), + ): + matches = list(distributions(name=name)) + if not matches: + continue + if len(matches) != 1: + raise SystemExit("Ambiguous governance runtime distribution: " + name) + installed = matches[0] + entries = [entry.value for entry in installed.entry_points + if entry.group == "console_scripts" and entry.name == command] + launcher = Path(sys.executable).parent / command + if (entries != [module + ".check:main"] + or not Path(installed.locate_file("")).resolve().is_relative_to(Path(sys.prefix).resolve()) + or launcher.is_symlink() or not launcher.is_file() or not os.access(launcher, os.X_OK)): + raise SystemExit("Unsupported governance runtime identity: " + name) + found.append(command) + if len(found) != 1: + raise SystemExit("Expected exactly one supported governance runtime") + print(found[0]) + PY_RUNTIME_COMMAND + )" + "$runtime_venv/bin/$runtime_command" --version + printf '%s\n' "$runtime_venv/bin" >> "$GITHUB_PATH" + - name: Run the managed governance gate + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + # CI has no local clone hook; checks bound to a developer clone are + # skipped for the ci actor, exactly as for governance_check.py below. + range_arguments=() + if [[ -n "${BASE_SHA:-}" || -n "${HEAD_SHA:-}" ]]; then + if [[ -z "${BASE_SHA:-}" || -z "${HEAD_SHA:-}" ]]; then + echo "Both BASE_SHA and HEAD_SHA are required for PR validation." >&2 + exit 1 + fi + range_arguments=(--base "$BASE_SHA" --head "$HEAD_SHA") + fi + # Resolve only the runtime installed from the independently selected pin. + runtime_module="$(python -I - <<'PY_GATE_RUNTIME' + import sys + from importlib.metadata import PackageNotFoundError, distribution + from pathlib import Path + + if sys.prefix == sys.base_prefix: + raise SystemExit("Governance runtime requires its isolated environment") + for name, command, module in ( + ("wellmanifest-governance", "wellman-governance", "wellmanifest_governance_runtime"), + ("wellman", "wellman", "wellman"), + ): + try: + installed = distribution(name) + except PackageNotFoundError: + continue + expected = module + ".check:main" + entries = [entry.value for entry in installed.entry_points + if entry.group == "console_scripts" and entry.name == command] + if (entries != [expected] + or not Path(installed.locate_file("")).resolve().is_relative_to(Path(sys.prefix).resolve())): + raise SystemExit("Unsupported governance runtime identity: " + name) + print(module) + break + else: + raise SystemExit("Governance runtime is not installed") + PY_GATE_RUNTIME + )" + python -I -m "$runtime_module" check --root . --actor ci --json "${range_arguments[@]}" + # The adopter's standard-adoption.json mode decides: "enforce" fails on + # missing packs, "audit" reports them without failing the gate. + python3 .governance/standard_pack_check.py --root . --format json + arguments=(--root . --manifest .governance/manifest.json + --lock .governance/manifest.lock.json + --stack-profiles .governance/stack-profiles.json --actor ci) + python3 .governance/governance_check.py "${arguments[@]}" "${range_arguments[@]}" diff --git a/.gitignore b/.gitignore index 7404796..986ce82 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,14 @@ __pycache__/ dist/ build/ .pytest_cache/ +/.venv/ + +# Repository-local worktrees and operational state +/.worktrees/ +/.subactor/leases/ +/.subactor/sessions/ +/.subactor/recovery/ +/.subactor/receipts/ +/.subactor/cache/ +/.subactor/snapshots/ +*.egg diff --git a/.governance/AGENT_DECISIONS.md b/.governance/AGENT_DECISIONS.md new file mode 100644 index 0000000..1a1e560 --- /dev/null +++ b/.governance/AGENT_DECISIONS.md @@ -0,0 +1,116 @@ +# Agent decisions within an authorized task + +This guidance composes session authorization, ticket ownership and workspace +observation. It grants no new Git, deployment, credential or cleanup authority. + +## Decide from the requested effect + +1. Recover the current request, recorded intent and existing session authority. + An instruction to execute remains valid for the same outcome across turns; + do not ask again merely because a phase, tool or checkout changed. A new + request to improve policy does not approve a previously proposed deletion. +2. Identify the next concrete effect, its owner, target and reversibility. Read + the applicable rule and its activation condition. A warning about a possible + destructive operation does not require choosing that operation. +3. Inspect current evidence before interpreting a failure: exact command and + diagnostic, Git identity and ancestry, actual dirty paths, active ticket, + protected receipts and, when relevant, active processes and leases. +4. Prefer a bounded route already authorized that preserves unknown work. + Continue disjoint implementation, tests and read-only diagnosis while a + dependent effect waits. Keep the original requested outcome active. +5. Ask only when necessary information or authority is still missing. Before + asking, finish the independent preparation so the choice is reviewable. + State the exact operation and target, evidence inspected, expected effect, + preservation or recovery plan, and the rule requiring a new decision. + Silence, elapsed time, a suggested answer and a diagnostic are not approval. + +| Observation | Decision | +| --- | --- | +| Same authorized scope, routine reversible fix and required tests | Proceed within the ticket. | +| Protected delivery is part of the authorized outcome | Invoke the declared validator/controller; its trusted evidence still governs merge/apply. | +| Detached snapshot shares the writer's HEAD and has no competing source delta | Preserve it; common history alone is not a second writer. | +| Branch without a worktree has every unique commit tree present in target history after divergence | Preserve the branch; managed admission can exclude that historical copy from competing deltas, without closing or discarding it. | +| Real competing dirty changes or active overlapping intent | Stop the affected write and resolve ownership; keep disjoint work progressing. | +| Missing or contradictory evidence | Report uncertainty, gather bounded observations; do not infer permission. | +| CI capacity, credentials or another external prerequisite is unavailable | Persist the exact blocker and remaining stages; do not manufacture successful checks. | +| Removing, relocating or repairing unknown work is necessary | Audit and preserve it, then obtain authority for that exact operation unless already explicitly authorized. | + +## Separate conflict evidence from cleanup authority + +`wellmanifest/worktrees` classifies registrations and their permitted placement. +An `unknown`, legacy or quarantine classification is not proof of a competing +write and does not require cleanup to continue an unrelated canonical ticket. +It also never makes that registration publishable or disposable. + +The overlap guard compares dirty changes with the peer's new contribution +since the pair's common ancestor. A shared implementation already present in +both HEADs is inherited context. Two actual dirty writers remain contested; +unresolved ancestry retains conservative checking. Intent ownership remains a +separate check even when Git can merge file contents. + +For a non-primary detached checkout with no commits ahead of the observed +target, a staged path already identical to that target is not an outstanding +contribution. The query verifies exact target blob and mode, stage-zero index +entry, and raw filesystem blob and mode. Different or unknown evidence remains +pending. Named branches and active intent retain their reservations. All dirty +paths remain visible, and the observation digest binds index entries as well +as filesystem content. This does not classify the checkout as disposable or +authorize its cleanup, lease transfer, source writes or publication. + +`wellmanifest/git-lifecycle` still owns cleanup effects; `ticket-lifecycle` +owns reservations, and protected merge/publication controllers own external +effects. Fix an incorrect checker at its HOME with a failing regression and +adopt the verified revision. Do not disable a hook, edit a managed hash by +hand, add a blanket ignore, or delete evidence merely to make a gate green. + +## Report the achieved stage + +### Cheap preflight before expensive validation + +First resolve the existing ticket and checkout, dirty paths, actual remote +publication and the next requested effect. The managed work-start query's +optional `--observe-publication` reports remote branch evidence without fetch; +its default local admission and authority boundaries remain unchanged. A local +branch ahead of `main` or its upstream can already be published on a different +remote ticket branch. Reconcile that binding, not an imaginary lost push. + +Before launching a long publication suite, use the declared publisher's +read-only preflight, when available, to check ticket/branch identity, accepted +base, commit-message syntax, delivery mode, configuration and pinned tools. +Report an unavailable preflight rather than inventing a command or bypassing +the publisher. Put the cheap checks first; still run required validation and +recheck exact HEAD and fencing at the effect boundary. There is no new gate. + +Report the current phase, elapsed time, evidence timestamp, exact HEAD and +next bounded action. Do not reset a retry counter or rerun an unchanged +deterministic failure as if it were progress. Cache only against all evidence +inputs; a cached test result never becomes trusted approval. + +### Recovery before another attempt + +Resolve the emitted diagnostic in the canonical diagnostics registry and use +its managed runbook. In particular, branch lifecycle `002` means a branch +without an open PR, whereas `003` means a missing, malformed or inconsistent +snapshot. Neither finding grants cleanup authority. Read closed PRs and exact +refs before deciding whether delivery, observation or reconciliation is needed. + +Every recovery answer names the next bounded action, its existing authority, +the verification that completes it and what remains preserved if it fails. +Reuse the current ticket, checkout and pending-effect journal. A repeated +deterministic failure with unchanged inputs calls for diagnosis or a changed +prerequisite, not another identical effect, fresh ticket or empty PR. A timed-out +remote operation is observed before retry; a matching remote head means the +push is already present, not that its PR was merged. + +Run the gate appropriate to the adopted delivery path; this guidance does not +create a draft-push exemption or waive a failed required check. Continue safe +diagnosis and authorized disjoint work while the dependent effect waits. + +### Evidence by stage + +Distinguish source edited, tests passed, commit created, PR open, trusted merge, +deployment applied and public behavior verified. Each claim needs evidence +from that stage. A local preview, HTTP 200, an unchanged version number, or a +completed coding ticket alone cannot prove the requested production change. +When blocked, preserve the work and name the remaining dependent effect; +do not describe the overall task as completed. diff --git a/.governance/adoption-bindings.json b/.governance/adoption-bindings.json new file mode 100644 index 0000000..7046f33 --- /dev/null +++ b/.governance/adoption-bindings.json @@ -0,0 +1,10 @@ +{ + "schema": "new-project.adoption-bindings/v2", + "revisionBoundWorkflowPaths": [ + ".github/workflows/governance.yml" + ], + "digestBoundTargetPatterns": [ + ".github/workflows/*.yml", + ".github/workflows/*.yaml" + ] +} diff --git a/.governance/adoption-bindings.schema.json b/.governance/adoption-bindings.schema.json new file mode 100644 index 0000000..1bdca9f --- /dev/null +++ b/.governance/adoption-bindings.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/new-project/adoption-bindings/v2", + "title": "new-project atomic adoption bindings", + "type": "object", + "additionalProperties": false, + "required": ["schema", "revisionBoundWorkflowPaths", "digestBoundTargetPatterns"], + "properties": { + "schema": {"const": "new-project.adoption-bindings/v2"}, + "revisionBoundWorkflowPaths": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+\\.ya?ml$" + } + }, + "digestBoundTargetPatterns": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^\\.github/workflows/[A-Za-z0-9._/*?-]+\\.ya?ml$" + } + } + } +} diff --git a/.governance/agent-hosts.json b/.governance/agent-hosts.json new file mode 100644 index 0000000..7455c3b --- /dev/null +++ b/.governance/agent-hosts.json @@ -0,0 +1,137 @@ +{ + "schema": "new-project.agent-hosts/v1", + "note": "Single source of truth for the host-agnostic agent contract (AGENTS.md rule 22). scripts/agent_host_check.py proves these files are present, that the fail-closed hook is installed and active, that source links point at the local adoption contract and concrete remote standard files, that bounded-session controls are present, and that the packaging metadata a repository already carries actually runs the gate. Instruction files are advisory to a model; the hook, the CI job and the packaging lifecycle bindings declared here are the parts that are not. Adding a host here without adding it to governance/package-manifest.json makes the requirement unshippable, so both change together.", + "hook": { + "path": ".githooks/pre-commit", + "hooksPathConfig": ".githooks", + "additionalHooks": [".githooks/commit-msg", ".githooks/pre-push"], + "runtimeFiles": [ + ".governance/worktree_guard.py", + ".governance/worktree_overlap_check.py", + ".governance/change_lease_check.py", + ".governance/change-lease.schema.json", + "worktree-guard.yaml" + ] + }, + "hosts": [ + { + "id": "generic", + "file": "AGENTS.md", + "readers": [ + "Codex", + "aider (via .aider.conf.yml read)", + "any AGENTS.md-aware host" + ] + }, + { + "id": "claude", + "file": "CLAUDE.md", + "readers": [ + "Claude Code" + ] + }, + { + "id": "gemini", + "file": "GEMINI.md", + "readers": [ + "Gemini CLI", + "Antigravity" + ] + }, + { + "id": "cursor", + "file": ".cursor/rules/new-project-standard.mdc", + "readers": [ + "Cursor" + ] + }, + { + "id": "aider", + "file": ".aider.conf.yml", + "readers": [ + "aider" + ] + }, + { + "id": "copilot", + "file": ".github/copilot-instructions.md", + "readers": [ + "GitHub Copilot", + "Copilot Chat in VS Code and JetBrains" + ] + } + ], + "sourceLinks": { + "schema": "new-project.agent-source-links/v1", + "authority": "Local adoption lock, local managed-file digests and protected validation are authoritative. Remote main URLs are navigation only; host instructions never fetch or execute them.", + "local": [ + {"id": "hub-manifest", "path": "governance/manifest.hub.json"}, + {"id": "hub-package", "path": "governance/package-manifest.json"}, + {"id": "adopter-manifest", "path": ".governance/manifest.json"}, + {"id": "adopter-lock", "path": ".governance/manifest.lock.json"}, + {"id": "adopter-package", "path": ".governance/package-manifest.json"} + ], + "remote": [ + {"id": "new-project-agents", "repository": "wellmanifest/new-project", "path": "template/files/AGENTS.template.md", "url": "https://github.com/wellmanifest/new-project/blob/main/template/files/AGENTS.template.md"}, + {"id": "new-project-hosts", "repository": "wellmanifest/new-project", "path": "governance/agent-hosts.json", "url": "https://github.com/wellmanifest/new-project/blob/main/governance/agent-hosts.json"}, + {"id": "new-project-adoption", "repository": "wellmanifest/new-project", "path": "scripts/create_adoption_lock.py", "url": "https://github.com/wellmanifest/new-project/blob/main/scripts/create_adoption_lock.py"}, + {"id": "worktrees-schema", "repository": "wellmanifest/worktrees", "path": "models/worktrees.schema.json", "url": "https://github.com/wellmanifest/worktrees/blob/main/models/worktrees.schema.json"}, + {"id": "git-lifecycle-schema", "repository": "wellmanifest/git-lifecycle", "path": "standard/git-lifecycle.schema.json", "url": "https://github.com/wellmanifest/git-lifecycle/blob/main/standard/git-lifecycle.schema.json"}, + {"id": "ticket-lifecycle-schema", "repository": "wellmanifest/ticket-lifecycle", "path": "standard/ticket-lifecycle.schema.json", "url": "https://github.com/wellmanifest/ticket-lifecycle/blob/main/standard/ticket-lifecycle.schema.json"}, + {"id": "policy-dsl", "repository": "wellmanifest/policy-dsl", "path": "spec/POLICY_DSL.md", "url": "https://github.com/wellmanifest/policy-dsl/blob/main/spec/POLICY_DSL.md"}, + {"id": "logs-contract", "repository": "wellmanifest/logs", "path": "contracts/logs.contract.json", "url": "https://github.com/wellmanifest/logs/blob/main/contracts/logs.contract.json"}, + {"id": "agent-schema", "repository": "wellmanifest/agent", "path": "standard/agent.schema.json", "url": "https://github.com/wellmanifest/agent/blob/main/standard/agent.schema.json"}, + {"id": "llm-policy", "repository": "wellmanifest/llm", "path": "README.md", "url": "https://github.com/wellmanifest/llm/blob/main/README.md"}, + {"id": "offer-pointer", "repository": "wellmanifest/offer", "path": "README.md", "url": "https://github.com/wellmanifest/offer/blob/main/README.md"}, + {"id": "brand-pointer", "repository": "wellmanifest/brand", "path": "README.md", "url": "https://github.com/wellmanifest/brand/blob/main/README.md"} + ], + "requiredInEveryHost": ["new-project-agents", "new-project-hosts", "new-project-adoption"], + "requiredInAgents": ["new-project-agents", "new-project-hosts", "new-project-adoption", "worktrees-schema", "git-lifecycle-schema", "ticket-lifecycle-schema", "policy-dsl", "logs-contract", "agent-schema", "llm-policy", "offer-pointer", "brand-pointer"] + }, + "anomalyChecks": { + "schema": "new-project.agent-guidance-audit/v1", + "maxInstructionBytes": 65536, + "requiredTerms": ["checkpoint", "handoff", "stop", "maxActiveMinutes"], + "contradictions": [ + { + "id": "direct-default-branch-delivery", + "patterns": ["push directly to main", "never push directly to main"] + }, + { + "id": "self-merge", + "patterns": ["merge directly", "never merge directly"] + } + ], + "ci": { + "requiredChecksCandidates": [ + "governance/required-checks.json", + ".governance/required-checks.json" + ] + } + }, + "packaging": { + "python": { + "marker": "pyproject.toml", + "declaration": "tool.wellmanifest", + "lifecycle": { + "kind": "pytest-plugin", + "field": "tool.pytest.ini_options.addopts", + "mustContain": "-p wellmanifest_governance" + } + }, + "node": { + "marker": "package.json", + "declaration": "wellmanifest", + "lifecycle": { + "kind": "npm-script", + "field": "scripts.prepare", + "mustContain": "install-agent-hosts" + } + } + }, + "declarationFields": { + "standard": "must equal .governance/manifest.lock.json standard.version", + "revision": "must equal .governance/manifest.lock.json standard.sourceRevision", + "gate": "repository-relative path of the managed governance gate" + } +} diff --git a/.governance/agent-hosts.schema.json b/.governance/agent-hosts.schema.json new file mode 100644 index 0000000..762f7e0 --- /dev/null +++ b/.governance/agent-hosts.schema.json @@ -0,0 +1,179 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/new-project/agent-hosts/v1", + "title": "new-project host-agnostic agent contract", + "description": "Declares the instruction files every LLM host loads, the fail-closed git hook, and the packaging touchpoints that make the contract executable rather than advisory.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "hook", "hosts", "sourceLinks", "anomalyChecks", "packaging", "declarationFields"], + "properties": { + "schema": { "const": "new-project.agent-hosts/v1" }, + "note": { "type": "string" }, + "hook": { + "type": "object", + "additionalProperties": false, + "required": ["path", "hooksPathConfig", "runtimeFiles"], + "properties": { + "path": { "type": "string", "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))[^/\\\\:\\x00-\\x1f\\x7f]+(?:/[^/\\\\:\\x00-\\x1f\\x7f]+)*$" }, + "hooksPathConfig": { "type": "string", "minLength": 1 }, + "additionalHooks": { + "type": "array", "uniqueItems": true, + "items": { "type": "string", "enum": [".githooks/commit-msg", ".githooks/prepare-commit-msg", ".githooks/pre-push"] } + }, + "runtimeFiles": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))[^/\\\\:\\x00-\\x1f\\x7f]+(?:/[^/\\\\:\\x00-\\x1f\\x7f]+)*$" } + } + } + }, + "hosts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "file", "readers"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "file": { "type": "string", "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))[^/\\\\:\\x00-\\x1f\\x7f]+(?:/[^/\\\\:\\x00-\\x1f\\x7f]+)*$" }, + "readers": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + } + }, + "sourceLinks": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "authority", "local", "remote", "requiredInEveryHost", "requiredInAgents"], + "properties": { + "schema": { "const": "new-project.agent-source-links/v1" }, + "authority": { "type": "string", "minLength": 1 }, + "local": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "path"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "path": { "type": "string", "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))[^/\\\\:\\x00-\\x1f\\x7f]+(?:/[^/\\\\:\\x00-\\x1f\\x7f]+)*$" } + } + } + }, + "remote": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "repository", "path", "url"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "repository": { "type": "string", "pattern": "^wellmanifest/[a-z0-9.-]+$" }, + "path": { "type": "string", "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))[^/\\\\:\\x00-\\x1f\\x7f]+(?:/[^/\\\\:\\x00-\\x1f\\x7f]+)*$" }, + "url": { "type": "string", "pattern": "^https://github\\.com/wellmanifest/[a-z0-9.-]+/blob/main/.+$" } + } + } + }, + "requiredInEveryHost": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" } + }, + "requiredInAgents": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" } + } + } + }, + "anomalyChecks": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "maxInstructionBytes", "requiredTerms", "contradictions", "ci"], + "properties": { + "schema": { "const": "new-project.agent-guidance-audit/v1" }, + "maxInstructionBytes": { "type": "integer", "minimum": 1 }, + "requiredTerms": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "contradictions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "patterns"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "patterns": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + } + }, + "ci": { + "type": "object", + "additionalProperties": false, + "required": ["requiredChecksCandidates"], + "properties": { + "requiredChecksCandidates": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + } + } + }, + "packaging": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["marker", "declaration", "lifecycle"], + "properties": { + "marker": { "type": "string", "minLength": 1 }, + "declaration": { "type": "string", "minLength": 1 }, + "lifecycle": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "field", "mustContain"], + "properties": { + "kind": { "enum": ["npm-script", "pytest-plugin"] }, + "field": { "type": "string", "minLength": 1 }, + "mustContain": { "type": "string", "minLength": 1 } + } + } + } + } + }, + "declarationFields": { + "type": "object", + "additionalProperties": false, + "required": ["standard", "revision", "gate"], + "properties": { + "standard": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "minLength": 1 }, + "gate": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/.governance/agent_host_check.py b/.governance/agent_host_check.py new file mode 100644 index 0000000..fa74697 --- /dev/null +++ b/.governance/agent_host_check.py @@ -0,0 +1,740 @@ +#!/usr/bin/env python3 +"""Prove that the host-agnostic agent contract is installed, not just documented. + +Instruction files are advisory: any model may ignore them. This validator checks +the parts that execute anyway — the fail-closed git hook, and the packaging +lifecycle hooks that `npm install` and `pytest` run without asking the agent. + +Hub reads `governance/agent-hosts.json`; adopters read `.governance/agent-hosts.json`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +try: # Python 3.11+ + import tomllib +except ImportError: # pragma: no cover - exercised on 3.10 runners + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: + # An adopter may declare requires-python >=3.10, where neither reader + # exists. Import must still succeed: governance_check.py reports an + # ImportError here as a missing managed validator, which turns an + # interpreter gap into a false sync defect. + tomllib = None # type: ignore[assignment] + +SCHEMA = "new-project.agent-hosts/v1" +CONTRACT_CANDIDATES = ("governance/agent-hosts.json", ".governance/agent-hosts.json") +LOCK_CANDIDATES = ("governance/manifest.lock.json", ".governance/manifest.lock.json") +SOURCE_LINK_MARKER = "" + + +@dataclass(order=True) +class Finding: + code: str + message: str + remediation: str + paths: list[str] = field(default_factory=list) + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def first_existing(root: Path, candidates: tuple[str, ...]) -> Path | None: + for candidate in candidates: + path = root / candidate + if path.is_file(): + return path + return None + + +def git_config(root: Path, key: str) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(root), "config", "--get", key], + capture_output=True, text=True, check=False, timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def is_work_tree(root: Path) -> bool: + try: + result = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"], + capture_output=True, text=True, check=False, timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 and result.stdout.strip() == "true" + + +def check_hosts(root: Path, contract: dict[str, Any]) -> list[Finding]: + findings: list[Finding] = [] + for host in contract["hosts"]: + relative = str(host["file"]) + if not (root / relative).is_file(): + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"Host instruction file for '{host['id']}' is missing: {relative}", + "Bootstrap with ./scripts/install-agent-hosts.sh --source --target , or adopt the current standard package.", + [relative], + )) + return findings + + +LOCAL_SOURCE_SETS = { + "governance/agent-hosts.json": { + "hub-manifest": "governance/manifest.hub.json", + "hub-package": "governance/package-manifest.json", + }, + ".governance/agent-hosts.json": { + "adopter-manifest": ".governance/manifest.json", + "adopter-lock": ".governance/manifest.lock.json", + "adopter-package": ".governance/package-manifest.json", + }, +} + + +def _local_source_paths( + root: Path, local: Any, contract_path: str | None, +) -> tuple[list[str], list[Finding]]: + """Check the selected layout; missing files must never select a weaker one.""" + def fail(message: str, paths: list[str]) -> tuple[list[str], list[Finding]]: + return [], [Finding( + "GOV-AGENT-HOST-004", message, + "Restore the complete local source contract through pinned standard adoption.", paths, + )] + + selected = first_existing(root, CONTRACT_CANDIDATES) if contract_path is None else Path(contract_path) + if selected is not None and contract_path is not None and not selected.is_absolute(): + selected = root / selected + try: + layout = selected.relative_to(root).as_posix() if selected is not None else "" + except ValueError: + layout = "" + expected = LOCAL_SOURCE_SETS.get(layout) + if expected is None: + return fail("Local sources require a canonical hub or adopter host-contract location.", ["sourceLinks"]) + if not isinstance(local, list) or any( + not isinstance(item, dict) or not isinstance(item.get("id"), str) + or not item["id"] or not isinstance(item.get("path"), str) or not item["path"] + for item in local + ): + return fail("Agent host local source declaration is malformed.", ["sourceLinks"]) + by_id = {item["id"]: item["path"] for item in local} + if len(by_id) != len(local): + return fail("Agent host local source declaration contains duplicate ids.", ["sourceLinks"]) + missing = [path for identifier, path in expected.items() if by_id.get(identifier) != path] + if missing: + return fail("Agent host local source declaration omits or changes required layout sources.", missing) + inactive = {identifier for candidate, group in LOCAL_SOURCE_SETS.items() if candidate != layout for identifier in group} + paths = [item["path"] for item in local if item["id"] not in inactive] + unsafe = [path for path in paths if not safe_declared_gate(root, path)] + if unsafe: + return fail("Declared local sources are missing, unsafe or not regular checkout files.", unsafe) + return paths, [] + + +def check_source_links(root: Path, contract: dict[str, Any], *, contract_path: str | None = None) -> list[Finding]: + """Require every managed host projection to expose its bounded sources. + + The local files prove which package is adopted. Remote links are deliberately + navigation-only and point to concrete files on the current standard branch; + no validator fetches them and they never replace the local lock/digests. + """ + findings: list[Finding] = [] + source_links = contract.get("sourceLinks") + if not isinstance(source_links, dict): + return [Finding( + "GOV-AGENT-HOST-004", + "Agent host contract has no source-links declaration.", + "Adopt the current standard package with its managed source-links contract.", + ["sourceLinks"], + )] + + local = source_links.get("local", []) + remote = source_links.get("remote", []) + remote_ids = [ + item.get("id") for item in remote + if isinstance(item, dict) and isinstance(item.get("id"), str) + ] + duplicate_ids = sorted({identifier for identifier in remote_ids if remote_ids.count(identifier) > 1}) + if duplicate_ids: + return [Finding( + "GOV-AGENT-HOST-004", + "Agent host source-links declaration contains duplicate remote ids: " + + ", ".join(duplicate_ids), + "Restore unique remote source identifiers in the managed host contract.", + ["sourceLinks"], + )] + by_id = { + item.get("id"): item for item in remote + if isinstance(item, dict) and isinstance(item.get("id"), str) + } + required_every = source_links.get("requiredInEveryHost", []) + required_agents = source_links.get("requiredInAgents", []) + required_ids = set(required_every) | set(required_agents) + missing_ids = sorted(identifier for identifier in required_ids if identifier not in by_id) + if missing_ids: + findings.append(Finding( + "GOV-AGENT-HOST-004", + "Agent host source-links declaration references unknown remote ids: " + + ", ".join(missing_ids), + "Restore the managed source-links contract from the standard package.", + ["sourceLinks"], + )) + return findings + + malformed_urls = [] + for identifier, item in by_id.items(): + repository = item.get("repository") + path = item.get("path") + url = item.get("url") + expected = f"https://github.com/{repository}/blob/main/{path}" + if not isinstance(url, str) or url != expected: + malformed_urls.append(identifier) + if malformed_urls: + findings.append(Finding( + "GOV-AGENT-HOST-004", + "Agent host source-links declaration contains non-canonical URLs: " + + ", ".join(sorted(malformed_urls)), + "Use the concrete main-branch URL derived from each declared repository and path.", + ["sourceLinks"], + )) + return findings + + local_paths, local_findings = _local_source_paths(root, local, contract_path) + if local_findings: + return findings + local_findings + + for host in contract["hosts"]: + relative = str(host["file"]) + path = root / relative + if not path.is_file(): + continue + try: + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"Host source links are unreadable in {relative}: {error}", + "Restore the managed host projection through standard adoption.", + [relative], + )) + continue + if SOURCE_LINK_MARKER not in content: + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"Host instruction file has no managed source-links marker: {relative}", + "Regenerate managed host instructions from the pinned standard package.", + [relative], + )) + for local_path in local_paths: + if local_path not in content: + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"Host instruction file omits local source link {local_path}: {relative}", + "Regenerate managed host instructions from the pinned standard package.", + [relative, local_path], + )) + remote_ids = required_agents if relative == "AGENTS.md" else required_every + for identifier in remote_ids: + url = by_id[identifier].get("url") + if not isinstance(url, str) or url not in content: + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"Host instruction file omits remote source link {identifier}: {relative}", + "Regenerate managed host instructions from the pinned standard package.", + [relative, identifier], + )) + return findings + + +def check_hook(root: Path, contract: dict[str, Any], actor: str) -> list[Finding]: + findings: list[Finding] = [] + hook_relative = str(contract["hook"]["path"]) + hook_path = root / hook_relative + if not hook_path.is_file(): + findings.append(Finding( + "GOV-AGENT-HOST-005", + f"Fail-closed pre-commit hook is missing: {hook_relative}", + "Bootstrap with ./scripts/install-agent-hosts.sh --source --target , or adopt the current standard package.", + [hook_relative], + )) + elif os.name != "nt" and not hook_path.stat().st_mode & 0o111: + findings.append(Finding( + "GOV-AGENT-HOST-005", + f"Fail-closed pre-commit hook is not executable: {hook_relative}", + f"Restore the executable bit with chmod +x {hook_relative}.", + [hook_relative], + )) + + additional = contract["hook"].get("additionalHooks", []) + if (not isinstance(additional, list) + or any(not isinstance(path, str) or path not in { + ".githooks/commit-msg", ".githooks/prepare-commit-msg", ".githooks/pre-push", + } for path in additional) + or len(additional) != len(set(additional))): + findings.append(Finding( + "GOV-AGENT-HOST-004", "Invalid additional hook declaration.", + "Restore the pinned host contract.", ["hook.additionalHooks"], + )) + return findings + for relative in additional: + path = root / relative + if not path.is_file() or (os.name != "nt" and not path.stat().st_mode & 0o111): + findings.append(Finding( + "GOV-AGENT-HOST-005", + f"Managed additional hook is missing or not executable: {relative}", + "Adopt the complete pinned package and run scripts/install-agent-hosts.sh.", + [relative], + )) + + # A CI checkout never runs local hooks; only a developer or agent clone can. + if actor == "ci" or not is_work_tree(root): + return findings + expected = str(contract["hook"]["hooksPathConfig"]) + configured = git_config(root, "core.hooksPath") + if configured != expected: + findings.append(Finding( + "GOV-AGENT-HOST-006", + f"core.hooksPath is {configured or 'unset'}; the managed hook is not active.", + f"Activate the managed hook: git config core.hooksPath {expected} in this clone.", + [hook_relative], + )) + return findings + + +def adopted_standard(root: Path) -> dict[str, Any]: + lock_path = first_existing(root, LOCK_CANDIDATES) + if lock_path is None: + return {} + try: + lock = load_json(lock_path) + except (OSError, json.JSONDecodeError): + return {} + standard = lock.get("standard") + return standard if isinstance(standard, dict) else {} + + +def python_declaration(marker: Path) -> tuple[dict[str, Any] | None, dict[str, Any]]: + if tomllib is None: + return None, {} + try: + document = tomllib.loads(marker.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return None, {} + if not isinstance(document, dict): + return None, {} + tool = document.get("tool") + declaration = tool.get("wellmanifest") if isinstance(tool, dict) else None + return (declaration if isinstance(declaration, dict) else None), document + + +def node_declaration(marker: Path) -> tuple[dict[str, Any] | None, dict[str, Any]]: + try: + document = load_json(marker) + except (OSError, json.JSONDecodeError): + return None, {} + if not isinstance(document, dict): + return None, {} + declaration = document.get("wellmanifest") + return (declaration if isinstance(declaration, dict) else None), document + + +def lifecycle_value(kind: str, document: dict[str, Any]) -> str: + if not isinstance(document, dict): + return "" + if kind == "npm-script": + scripts = document.get("scripts") + value = scripts.get("prepare") if isinstance(scripts, dict) else None + else: + tool = document.get("tool") + pytest = tool.get("pytest") if isinstance(tool, dict) else None + options = pytest.get("ini_options") if isinstance(pytest, dict) else None + value = options.get("addopts") if isinstance(options, dict) else None + if isinstance(value, list): + return " ".join(str(item) for item in value) + return value if isinstance(value, str) else "" + + +def check_packaging(root: Path, contract: dict[str, Any]) -> list[Finding]: + findings: list[Finding] = [] + standard = adopted_standard(root) + for ecosystem, binding in sorted(contract["packaging"].items()): + marker_relative = str(binding["marker"]) + marker = root / marker_relative + if not marker.is_file(): + continue # The ecosystem is not present in this repository. + if ecosystem == "python" and tomllib is None: + continue # No TOML reader on this interpreter; 3.11+ jobs enforce it. + reader = python_declaration if ecosystem == "python" else node_declaration + declaration, document = reader(marker) + if declaration is None: + findings.append(Finding( + "GOV-PACKAGING-001", + f"{marker_relative} declares no '{binding['declaration']}' governance block.", + "Declare the adopted standard version, revision and gate in the package metadata.", + [marker_relative], + )) + else: + findings.extend(check_declaration(root, marker_relative, declaration, standard)) + + lifecycle = binding["lifecycle"] + if lifecycle["mustContain"] not in lifecycle_value(str(lifecycle["kind"]), document): + findings.append(Finding( + "GOV-PACKAGING-003", + f"{marker_relative} {lifecycle['field']} does not run '{lifecycle['mustContain']}'.", + "Bind the governance gate to the packaging lifecycle so the tooling runs it unprompted.", + [marker_relative], + )) + return findings + + +def safe_declared_gate(root: Path, relative: str) -> bool: + """Observe an existing repository-relative regular file without link escapes. + + This is a path/declaration check, not permission to execute the gate. + """ + if re.fullmatch('^(?!.*(?:^|/)\\.\\.(?:/|$))[^/\\\\:\\x00-\\x1f\\x7f]+(?:/[^/\\\\:\\x00-\\x1f\\x7f]+)*$', relative) is None: + return False + try: + base = root.resolve(strict=True) + path = base + for part in relative.split("/"): + path = path / part + if path.is_symlink(): + return False + resolved = path.resolve(strict=True) + resolved.relative_to(base) + return resolved.is_file() + except (OSError, ValueError): + return False + + +def check_declaration( + root: Path, + marker_relative: str, + declaration: dict[str, Any], + standard: dict[str, Any], +) -> list[Finding]: + findings: list[Finding] = [] + for key in ("standard", "revision", "gate"): + if not isinstance(declaration.get(key), str) or not declaration[key].strip(): + findings.append(Finding( + "GOV-PACKAGING-001", + f"{marker_relative} governance block is missing the '{key}' field.", + "Declare the adopted standard version, revision and gate in the package metadata.", + [marker_relative], + )) + gate = declaration.get("gate") + if isinstance(gate, str) and gate.strip() and not safe_declared_gate(root, gate): + findings.append(Finding( + "GOV-PACKAGING-002", + f"{marker_relative} declares gate '{gate}', which is missing or unsafe.", + "Point the declaration at an existing repository-relative regular governance gate without parent traversal, drive paths or symbolic links.", + [marker_relative, gate], + )) + if not standard: + return findings + for key, locked in (("standard", "version"), ("revision", "sourceRevision")): + expected = standard.get(locked) + actual = declaration.get(key) + if isinstance(expected, str) and isinstance(actual, str) and actual != expected: + findings.append(Finding( + "GOV-PACKAGING-002", + f"{marker_relative} declares {key} '{actual}' but the adoption lock pins '{expected}'.", + "Regenerate the package declaration from .governance/manifest.lock.json.", + [marker_relative], + )) + return findings + + +def workflow_job_names(path: Path) -> list[str]: + """Use the adjacent managed validator for the offline workflow contract. + + Load by the package path, never by a target-controlled import search path. + This shares direct-job names and digest-bound reusable callee resolution. + Missing or unsupported contracts remain refusals, not guessed check names. + """ + import importlib.util + + checker_path = Path(__file__).resolve().with_name("check_required_checks.py") + spec = importlib.util.spec_from_file_location("agent_host_required_checks", checker_path) + if spec is None or spec.loader is None: + raise ImportError("managed required-checks validator is missing") + checker = importlib.util.module_from_spec(spec) + spec.loader.exec_module(checker) + return checker.workflow_job_names(path) + + +def check_guidance_anomalies(root: Path, contract: dict[str, Any]) -> list[Finding]: + """Catch bounded-session and impossible-CI hazards before model work begins. + + This is intentionally static and offline. It does not fetch remote links, + infer intent from prose, or retry a failed command. A finding is a stop + signal with a concrete path, not an invitation to keep experimenting. + """ + config = contract.get("anomalyChecks") + if not isinstance(config, dict): + return [Finding( + "GOV-AGENT-HOST-004", + "Agent host contract has no deterministic anomaly-check declaration.", + "Adopt the current standard package with its bounded-session audit contract.", + ["anomalyChecks"], + )] + + findings: list[Finding] = [] + max_bytes = config.get("maxInstructionBytes") + required_terms = config.get("requiredTerms", []) + contradictions = config.get("contradictions", []) + if not isinstance(max_bytes, int) or max_bytes < 1: + return [Finding( + "GOV-AGENT-HOST-004", + "Agent host anomaly contract has an invalid instruction-size limit.", + "Declare a positive maxInstructionBytes value in the managed contract.", + ["anomalyChecks"], + )] + + for host in contract["hosts"]: + relative = str(host["file"]) + path = root / relative + if not path.is_file(): + continue # check_hosts emits the more direct missing-file finding. + try: + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"Host guidance cannot be audited in {relative}: {error}", + "Restore the managed host projection from the pinned package.", + [relative], + )) + continue + size = len(content.encode("utf-8")) + if size > max_bytes: + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"Host guidance exceeds the bounded instruction size ({size} > {max_bytes} bytes): {relative}", + "Split or shorten the managed guidance before the host truncates its instruction chain.", + [relative], + )) + folded = content.casefold() + missing = [ + str(term) for term in required_terms + if isinstance(term, str) and term.casefold() not in folded + ] + if missing: + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"Host guidance lacks bounded-session controls {', '.join(missing)}: {relative}", + "Restore checkpoint, handoff and stop conditions so a blocked session cannot retry indefinitely.", + [relative], + )) + for rule in contradictions: + if not isinstance(rule, dict): + continue + patterns = rule.get("patterns") + if not isinstance(patterns, list) or len(patterns) < 2: + continue + present = [str(pattern) for pattern in patterns + if isinstance(pattern, str) and pattern.casefold() in folded] + if len(present) == len(patterns): + identifier = str(rule.get("id", "unnamed")) + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"Host guidance contains contradictory directives ({identifier}): {relative}", + "Remove one directive or split the rules by an explicit, machine-checkable scope.", + [relative], + )) + + ci = config.get("ci") + if not isinstance(ci, dict): + return findings + candidates = ci.get("requiredChecksCandidates", []) + checks_path = next( + (root / str(candidate) for candidate in candidates + if isinstance(candidate, str) and (root / candidate).is_file()), + None, + ) + if checks_path is None: + findings.append(Finding( + "GOV-AGENT-HOST-004", + "CI anomaly audit cannot find a required-checks declaration.", + "Restore governance/required-checks.json or .governance/required-checks.json.", + ["required-checks"], + )) + return findings + try: + declaration = load_json(checks_path) + except (OSError, json.JSONDecodeError) as error: + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"CI required-checks declaration is unreadable: {error}", + "Restore a valid managed required-checks declaration.", + [str(checks_path.relative_to(root))], + )) + return findings + if not isinstance(declaration, dict): + return findings + pairs: list[tuple[str, str]] = [] + bound = declaration.get("requiredChecks") + if isinstance(bound, list) and bound: + for item in bound: + if isinstance(item, dict) and isinstance(item.get("name"), str) and isinstance(item.get("workflowFile"), str): + pairs.append((item["name"], item["workflowFile"])) + elif isinstance(declaration.get("requiredCheckNames"), list) and isinstance(declaration.get("workflowFile"), str): + pairs = [(str(name), declaration["workflowFile"]) + for name in declaration["requiredCheckNames"] + if isinstance(name, str)] + if not pairs: + findings.append(Finding( + "GOV-AGENT-HOST-004", + "CI required-checks declaration has no usable check/workflow pairs.", + "Declare requiredCheckNames with workflowFile, or bound requiredChecks entries.", + [str(checks_path.relative_to(root))], + )) + return findings + for workflow in sorted({workflow for _, workflow in pairs}): + workflow_path = root / workflow + if not workflow_path.is_file(): + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"CI required-checks declaration names a missing workflow: {workflow}", + "Point workflowFile at a workflow present in this checkout.", + [workflow, str(checks_path.relative_to(root))], + )) + continue + try: + published = workflow_job_names(workflow_path) + except (OSError, UnicodeDecodeError, ImportError, ValueError, SystemExit): + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"CI workflow contract cannot be resolved: {workflow}.", + "Restore the managed validator and an immutable digest-bound reusable source; run check_required_checks.py for the precise refusal.", + [workflow, str(checks_path.relative_to(root))], + )) + continue + for name, declared_workflow in pairs: + if declared_workflow != workflow: + continue + if name not in published: + findings.append(Finding( + "GOV-AGENT-HOST-004", + f"CI required check {name!r} is not published by {workflow}.", + "Add the job or change the declaration to a job this workflow actually publishes; do not retry a permanently impossible gate.", + [workflow, str(checks_path.relative_to(root))], + )) + return findings + + +def load_contract(root: Path, explicit: str | None) -> tuple[dict[str, Any] | None, Finding | None]: + if explicit is not None: + path = root / explicit if not Path(explicit).is_absolute() else Path(explicit) + if not path.is_file(): + return None, Finding( + "GOV-AGENT-HOST-004", f"Agent host contract is missing: {explicit}", + "Adopt the current standard package or pass an existing --contract path.", [explicit], + ) + else: + found = first_existing(root, CONTRACT_CANDIDATES) + if found is None: + # The repository has not received the managed contract yet, so there + # is nothing to verify. Deleting it later is not an escape hatch: + # agent-hosts.json is a managed file and GOV-SYNC-001 catches its + # removal against the adoption lock. + return None, None + path = found + try: + contract = load_json(path) + except (OSError, json.JSONDecodeError) as error: + return None, Finding( + "GOV-AGENT-HOST-004", f"Agent host contract is unreadable: {error}", + "Restore the pinned host contract through a standard upgrade.", [str(path.name)], + ) + if not isinstance(contract, dict) or contract.get("schema") != SCHEMA: + return None, Finding( + "GOV-AGENT-HOST-004", f"Agent host contract must declare schema {SCHEMA}.", + "Restore the pinned host contract through a standard upgrade.", [str(path.name)], + ) + for key in ("hook", "hosts", "sourceLinks", "packaging", "anomalyChecks"): + if key not in contract: + return None, Finding( + "GOV-AGENT-HOST-004", f"Agent host contract has no '{key}' section.", + "Restore the pinned host contract through a standard upgrade.", [str(path.name)], + ) + return contract, None + + +def audit(root: Path, actor: str = "agent", contract_path: str | None = None) -> dict[str, Any]: + contract, failure = load_contract(root, contract_path) + if contract is None: + findings = [failure] if failure is not None else [] + else: + findings = ( + check_hosts(root, contract) + + check_source_links(root, contract, contract_path=contract_path) + + check_hook(root, contract, actor) + + check_packaging(root, contract) + + check_guidance_anomalies(root, contract) + ) + findings.sort() + return { + "schema": "new-project.agent-host-report/v1", + "actor": actor, + "findings": [ + { + "code": item.code, + "message": item.message, + "remediation": item.remediation, + "paths": item.paths, + } + for item in findings + ], + "ok": not findings, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=str(repo_root())) + parser.add_argument("--contract", default=None, help="Explicit agent-hosts.json path") + parser.add_argument( + "--actor", choices=("agent", "human", "ci"), default="agent", + help="'ci' skips checks that only a developer or agent clone can satisfy", + ) + parser.add_argument("--format", choices=("text", "json"), default="text") + args = parser.parse_args(argv or sys.argv[1:]) + + report = audit(Path(args.root).resolve(), args.actor, args.contract) + if args.format == "json": + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + status = "GOV-AGENT-HOST-PASS" if report["ok"] else "GOV-AGENT-HOST-FAIL" + print(f"{status}: {len(report['findings'])} findings") + for finding in report["findings"]: + print(f"{finding['code']}: {finding['message']}") + print(f" -> {finding['remediation']}") + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/approval-evidence.schema.json b/.governance/approval-evidence.schema.json new file mode 100644 index 0000000..0c34f54 --- /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/branch-intent-reconciliation.schema.json b/.governance/branch-intent-reconciliation.schema.json new file mode 100644 index 0000000..2f29a74 --- /dev/null +++ b/.governance/branch-intent-reconciliation.schema.json @@ -0,0 +1,544 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/new-project/branch-intent-reconciliation.schema.json", + "title": "Branch intent reconciliation artifacts (conformance is not authority)", + "oneOf": [ + { + "$ref": "#/$defs/report" + }, + { + "$ref": "#/$defs/observation" + }, + { + "$ref": "#/$defs/evidence" + } + ], + "$defs": { + "bindings": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "sourceRef", + "sourceHeadSha", + "targetRef", + "targetHeadSha", + "intentSha256" + ], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "sourceRef": { + "type": "string", + "pattern": "^refs/heads/.+" + }, + "sourceHeadSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "targetRef": { + "type": "string", + "pattern": "^refs/heads/.+" + }, + "targetHeadSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "intentSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "reference": { + "type": "object", + "additionalProperties": false, + "required": [ + "receiptRef", + "sha256" + ], + "properties": { + "receiptRef": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "report": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "bindings", + "preservation", + "criteria" + ], + "properties": { + "schema": { + "const": "new-project.branch-intent-reconciliation/v1" + }, + "bindings": { + "$ref": "#/$defs/bindings" + }, + "preservation": { + "$ref": "#/$defs/reference" + }, + "criteria": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "outcome", + "evidence", + "followUp", + "decision" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "outcome": { + "enum": [ + "implemented", + "partial", + "superseded", + "missing", + "unknown" + ] + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/$defs/reference" + }, + "uniqueItems": true + }, + "followUp": { + "oneOf": [ + { + "$ref": "#/$defs/reference" + }, + { + "type": "null" + } + ] + }, + "decision": { + "oneOf": [ + { + "$ref": "#/$defs/reference" + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, + "observation": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "bindings", + "criteria", + "receipts" + ], + "properties": { + "schema": { + "const": "new-project.branch-intent-observation/v1" + }, + "bindings": { + "$ref": "#/$defs/bindings" + }, + "criteria": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "requiredProof" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "requiredProof": { + "enum": [ + "content", + "behavior", + "either" + ] + } + } + } + }, + "receipts": { + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + }, + "evidence": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "receiptRef", + "kind", + "bindings", + "criterionIds", + "facts" + ], + "properties": { + "schema": { + "const": "new-project.branch-intent-evidence/v1" + }, + "receiptRef": { + "type": "string", + "minLength": 1 + }, + "kind": { + "const": "preservation" + }, + "bindings": { + "$ref": "#/$defs/bindings" + }, + "criterionIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "facts": { + "type": "object", + "additionalProperties": false, + "required": [ + "archiveRef", + "archiveSha256", + "restoreVerified" + ], + "properties": { + "archiveRef": { + "type": "string", + "minLength": 1 + }, + "archiveSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "restoreVerified": { + "const": true + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "receiptRef", + "kind", + "bindings", + "criterionIds", + "facts" + ], + "properties": { + "schema": { + "const": "new-project.branch-intent-evidence/v1" + }, + "receiptRef": { + "type": "string", + "minLength": 1 + }, + "kind": { + "const": "content-equivalence" + }, + "bindings": { + "$ref": "#/$defs/bindings" + }, + "criterionIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "facts": { + "type": "object", + "additionalProperties": false, + "required": [ + "sourcePath", + "targetPath", + "sourceSha256", + "targetSha256" + ], + "properties": { + "sourcePath": { + "type": "string", + "minLength": 1 + }, + "targetPath": { + "type": "string", + "minLength": 1 + }, + "sourceSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "targetSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "receiptRef", + "kind", + "bindings", + "criterionIds", + "facts" + ], + "properties": { + "schema": { + "const": "new-project.branch-intent-evidence/v1" + }, + "receiptRef": { + "type": "string", + "minLength": 1 + }, + "kind": { + "const": "test-result" + }, + "bindings": { + "$ref": "#/$defs/bindings" + }, + "criterionIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "facts": { + "type": "object", + "additionalProperties": false, + "required": [ + "suiteSha256", + "resultSha256", + "passed" + ], + "properties": { + "suiteSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "resultSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "passed": { + "const": true + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "receiptRef", + "kind", + "bindings", + "criterionIds", + "facts" + ], + "properties": { + "schema": { + "const": "new-project.branch-intent-evidence/v1" + }, + "receiptRef": { + "type": "string", + "minLength": 1 + }, + "kind": { + "const": "decision" + }, + "bindings": { + "$ref": "#/$defs/bindings" + }, + "criterionIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "facts": { + "type": "object", + "additionalProperties": false, + "required": [ + "decisionRef", + "actor", + "disposition" + ], + "properties": { + "decisionRef": { + "type": "string", + "minLength": 1 + }, + "actor": { + "type": "string", + "minLength": 1 + }, + "disposition": { + "enum": [ + "superseded", + "discard" + ] + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "receiptRef", + "kind", + "bindings", + "criterionIds", + "facts" + ], + "properties": { + "schema": { + "const": "new-project.branch-intent-evidence/v1" + }, + "receiptRef": { + "type": "string", + "minLength": 1 + }, + "kind": { + "const": "follow-up" + }, + "bindings": { + "$ref": "#/$defs/bindings" + }, + "criterionIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "facts": { + "type": "object", + "additionalProperties": false, + "required": [ + "ticketRef", + "intentSha256" + ], + "properties": { + "ticketRef": { + "type": "string", + "minLength": 1 + }, + "intentSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "receiptRef", + "kind", + "bindings", + "criterionIds", + "facts" + ], + "properties": { + "schema": { + "const": "new-project.branch-intent-evidence/v1" + }, + "receiptRef": { + "type": "string", + "minLength": 1 + }, + "kind": { + "const": "advisory" + }, + "bindings": { + "$ref": "#/$defs/bindings" + }, + "criterionIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "facts": { + "type": "object", + "additionalProperties": false, + "required": [ + "analysisRef" + ], + "properties": { + "analysisRef": { + "type": "string", + "minLength": 1 + } + } + } + } + } + ] + } + } +} diff --git a/.governance/branch_intent_reconciliation.py b/.governance/branch_intent_reconciliation.py new file mode 100644 index 0000000..a15f92c --- /dev/null +++ b/.governance/branch_intent_reconciliation.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Read-only conformance of branch intent reports, never deletion authority. + +The controller supplies independently acquired expectations and a verified +receipt allowlist. Content-addressed receipts are read locally; no commands, +network requests, repository writes or automatic approvals are performed. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path + + +BINDINGS = {"repository", "sourceRef", "sourceHeadSha", "targetRef", "targetHeadSha", "intentSha256"} +OUTCOMES = {"implemented", "partial", "superseded", "missing", "unknown"} +KINDS = {"preservation", "content-equivalence", "test-result", "decision", "follow-up", "advisory"} + + +class Invalid(ValueError): + """A closed contract or a required evidence binding is invalid.""" + + +def require(condition, message): + if not condition: + raise Invalid(message) + + +def fields(value, names, label): + require(isinstance(value, dict) and set(value) == set(names), f"{label}: missing or unknown fields") + + +def string(value, label): + require(isinstance(value, str) and bool(value.strip()), f"{label}: nonempty string required") + + +def digest(value, length=64): + require(isinstance(value, str) and re.fullmatch(f"[0-9a-f]{{{length}}}", value), "invalid digest") + + +def unique_strings(values, label, nonempty=True): + require(isinstance(values, list) and (bool(values) or not nonempty), f"{label}: list required") + for value in values: + string(value, label) + require(len(set(values)) == len(values), f"{label}: duplicates") + + +def bindings(value): + fields(value, BINDINGS, "bindings") + for key in BINDINGS: + string(value[key], key) + for key in ("sourceHeadSha", "targetHeadSha"): + digest(value[key], 40) + digest(value["intentSha256"]) + for key in ("sourceRef", "targetRef"): + require(value[key].startswith("refs/heads/") and len(value[key]) > len("refs/heads/"), + "exact branch refs required") + require(value["sourceRef"] != value["targetRef"], "source and target refs must differ") + + +def parse_json(content): + def pairs(items): + result = {} + for key, value in items: + require(key not in result, "duplicate JSON key") + result[key] = value + return result + return json.loads(content, object_pairs_hook=pairs, + parse_constant=lambda value: (_ for _ in ()).throw(Invalid("nonfinite JSON value"))) + + +def read_json(path): + return parse_json(Path(path).read_bytes()) + + +def expectations(value): + fields(value, {"schema", "bindings", "criteria", "receipts"}, "observation") + require(value["schema"] == "new-project.branch-intent-observation/v1", "wrong observation schema") + bindings(value["bindings"]) + require(isinstance(value["criteria"], list) and value["criteria"], "empty criterion inventory") + criteria = {} + for item in value["criteria"]: + fields(item, {"id", "requiredProof"}, "expected criterion") + string(item["id"], "criterion id") + require(item["id"] not in criteria, "duplicate expected criterion") + require(item["requiredProof"] in ("content", "behavior", "either"), "unknown required proof") + criteria[item["id"]] = item["requiredProof"] + require(isinstance(value["receipts"], dict), "receipt allowlist required") + for ref, sha in value["receipts"].items(): + string(ref, "receipt ref") + digest(sha) + return criteria + + +def evidence(reference, observation, directory): + fields(reference, {"receiptRef", "sha256"}, "evidence reference") + string(reference["receiptRef"], "receipt ref") + digest(reference["sha256"]) + require(observation["receipts"].get(reference["receiptRef"]) == reference["sha256"], + "receipt absent from independently verified allowlist") + path = directory / (reference["sha256"] + ".json") + require(not path.is_symlink() and path.is_file(), "receipt must be a regular local artifact") + content = path.read_bytes() + require(hashlib.sha256(content).hexdigest() == reference["sha256"], "receipt digest mismatch") + item = parse_json(content) + fields(item, {"schema", "receiptRef", "kind", "bindings", "criterionIds", "facts"}, "receipt") + require(item["schema"] == "new-project.branch-intent-evidence/v1", "wrong evidence schema") + require(item["receiptRef"] == reference["receiptRef"], "receipt identity mismatch") + require(item["bindings"] == observation["bindings"], "stale or mismatched evidence bindings") + require(item["kind"] in KINDS, "unknown evidence kind") + unique_strings(item["criterionIds"], "evidence criteria", nonempty=False) + known = {row["id"] for row in observation["criteria"]} + require(set(item["criterionIds"]) <= known, "evidence cites unknown criteria") + facts = item["facts"] + if item["kind"] == "content-equivalence": + fields(facts, {"sourcePath", "targetPath", "sourceSha256", "targetSha256"}, "content facts") + for key in ("sourcePath", "targetPath"): + string(facts[key], key) + for key in ("sourceSha256", "targetSha256"): + digest(facts[key]) + require(facts["sourceSha256"] == facts["targetSha256"], "content is not byte-equivalent") + elif item["kind"] == "test-result": + fields(facts, {"suiteSha256", "resultSha256", "passed"}, "test facts") + digest(facts["suiteSha256"]) + digest(facts["resultSha256"]) + require(facts["passed"] is True, "behavioral evidence did not pass") + elif item["kind"] == "preservation": + fields(facts, {"archiveRef", "archiveSha256", "restoreVerified"}, "preservation facts") + string(facts["archiveRef"], "archive ref") + digest(facts["archiveSha256"]) + require(facts["restoreVerified"] is True, "restoration not verified") + elif item["kind"] == "decision": + fields(facts, {"decisionRef", "actor", "disposition"}, "decision facts") + string(facts["decisionRef"], "decision ref") + string(facts["actor"], "decision actor") + require(facts["disposition"] in ("superseded", "discard"), "invalid decision disposition") + elif item["kind"] == "follow-up": + fields(facts, {"ticketRef", "intentSha256"}, "follow-up facts") + string(facts["ticketRef"], "follow-up ticket") + digest(facts["intentSha256"]) + else: + fields(facts, {"analysisRef"}, "advisory facts") + string(facts["analysisRef"], "analysis ref") + return item + + +def reconcile_criterion_disposition(row, criteria, proof, decision, follow, unresolved): + if row["outcome"] in ("implemented", "partial"): + required = {"content": {"content-equivalence"}, "behavior": {"test-result"}, + "either": {"content-equivalence", "test-result"}}[criteria[row["id"]]] + require(any(item["kind"] in required for item in proof), "implementation proof required; advisory is insufficient") + if row["outcome"] == "superseded": + require(decision is not None and decision["facts"]["disposition"] == "superseded", "superseding decision required") + require(follow is None, "superseded criterion cannot also defer work") + elif row["outcome"] in ("partial", "missing"): + require(decision is None or decision["facts"]["disposition"] == "discard", + "remaining work has contradictory decision") + require(follow is not None or (decision is not None and decision["facts"]["disposition"] == "discard"), + "remaining work needs a follow-up or explicit discard decision") + elif row["outcome"] == "unknown": + unresolved.append(row["id"]) + elif row["outcome"] == "implemented": + require(decision is None and follow is None, "implemented criterion has contradictory disposition") + + +def reconcile(report, observation, evidence_root): + """Return review readiness only; the caller owns observation authenticity.""" + criteria = expectations(observation) + fields(report, {"schema", "bindings", "preservation", "criteria"}, "report") + require(report["schema"] == "new-project.branch-intent-reconciliation/v1", "wrong report schema") + require(report["bindings"] == observation["bindings"], "stale or mismatched report bindings") + directory = Path(evidence_root).resolve(strict=True) + saved = evidence(report["preservation"], observation, directory) + require(saved["kind"] == "preservation", "preservation evidence required") + require(isinstance(report["criteria"], list), "report criteria must be a list") + seen, unresolved = set(), [] + for row in report["criteria"]: + fields(row, {"id", "outcome", "evidence", "followUp", "decision"}, "criterion") + string(row["id"], "criterion id") + require(row["id"] in criteria and row["id"] not in seen, "unknown or duplicate criterion") + seen.add(row["id"]) + require(row["outcome"] in OUTCOMES, "unknown criterion outcome") + require(isinstance(row["evidence"], list), "criterion evidence must be a list") + proof = [evidence(ref, observation, directory) for ref in row["evidence"]] + require(len({item["receiptRef"] for item in proof}) == len(proof), "duplicate criterion evidence") + for item in proof: + require(row["id"] in item["criterionIds"], "evidence does not cite this criterion") + decision = evidence(row["decision"], observation, directory) if row["decision"] is not None else None + follow = evidence(row["followUp"], observation, directory) if row["followUp"] is not None else None + for item, kind in ((decision, "decision"), (follow, "follow-up")): + if item is not None: + require(item["kind"] == kind and row["id"] in item["criterionIds"], "wrong disposition evidence") + reconcile_criterion_disposition(row, criteria, proof, decision, follow, unresolved) + require(seen == set(criteria), "report omits expected criteria") + return {"schema": "new-project.branch-intent-result/v1", "status": "needs-review" if unresolved else "ready-for-owner-review", + "unresolvedCriteria": sorted(unresolved), "authority": "none", "deletionAuthorized": False} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", required=True) + parser.add_argument("--observation", required=True) + parser.add_argument("--evidence-root", required=True) + args = parser.parse_args() + try: + result = reconcile(read_json(args.report), read_json(args.observation), args.evidence_root) + except (Invalid, OSError, ValueError, TypeError, KeyError) as exc: + print(json.dumps({"status": "invalid", "code": "GOV-BRANCH-INTENT-001", "message": str(exc), + "authority": "none", "deletionAuthorized": False})) + return 2 + print(json.dumps(result, sort_keys=True)) + return 0 if result["status"] == "ready-for-owner-review" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/branch_lifecycle_check.py b/.governance/branch_lifecycle_check.py new file mode 100755 index 0000000..20b46c0 --- /dev/null +++ b/.governance/branch_lifecycle_check.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Validate a versioned GitHub branch lifecycle snapshot without network access.""" + +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +SNAPSHOT_SCHEMA = "new-project.branch-lifecycle-snapshot/v1" +REPORT_SCHEMA = "new-project.branch-lifecycle-report/v1" +MAX_ITEMS = 10_000 +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + + +@dataclass(order=True) +class Finding: + code: str + severity: str + message: str + remediation: str + evidence: dict[str, Any] + + +class SnapshotError(ValueError): + """A closed snapshot contract or its internal consistency is invalid.""" + + +def require_exact_fields(value: dict[str, Any], fields: set[str], label: str) -> None: + observed = set(value) + if observed != fields: + missing = sorted(fields - observed) + extra = sorted(observed - fields) + raise SnapshotError(f"{label} fields are invalid (missing={missing}, extra={extra})") + + +def require_repository(value: Any, label: str, *, nullable: bool = False) -> str | None: + if value is None and nullable: + return None + if not isinstance(value, str) or not REPOSITORY_RE.fullmatch(value): + raise SnapshotError(f"{label} must be an owner/repository identifier") + return value + + +def require_ref(value: Any, label: str) -> str: + if ( + not isinstance(value, str) + or not value + or len(value) > 255 + or any(ord(character) < 32 or ord(character) == 127 for character in value) + ): + raise SnapshotError(f"{label} must be a non-empty bounded Git ref name") + return value + + +def parse_snapshot_pulls(pulls_value: Any) -> list[dict[str, Any]]: + if not isinstance(pulls_value, list) or len(pulls_value) > MAX_ITEMS: + raise SnapshotError(f"openPullRequests must be an array with at most {MAX_ITEMS} items") + pulls: list[dict[str, Any]] = [] + numbers: set[int] = set() + for index, item in enumerate(pulls_value): + if not isinstance(item, dict): + raise SnapshotError(f"openPullRequests[{index}] must be an object") + require_exact_fields(item, {"number", "headRepository", "headRef"}, f"openPullRequests[{index}]") + number = item["number"] + if not isinstance(number, int) or isinstance(number, bool) or number < 1: + raise SnapshotError(f"openPullRequests[{index}].number must be a positive integer") + if number in numbers: + raise SnapshotError("openPullRequests must not contain duplicate numbers") + numbers.add(number) + pulls.append( + { + "number": number, + "headRepository": require_repository( + item["headRepository"], + f"openPullRequests[{index}].headRepository", + nullable=True, + ), + "headRef": require_ref(item["headRef"], f"openPullRequests[{index}].headRef"), + } + ) + return pulls + + +def parse_snapshot(value: Any, expected_repository: str | None) -> dict[str, Any]: + if not isinstance(value, dict): + raise SnapshotError("snapshot root must be an object") + require_exact_fields( + value, + { + "schema", + "repository", + "defaultBranch", + "deleteBranchOnMerge", + "branches", + "openPullRequests", + }, + "snapshot", + ) + if value["schema"] != SNAPSHOT_SCHEMA: + raise SnapshotError(f"unsupported snapshot schema: {value['schema']!r}") + repository = require_repository(value["repository"], "repository") + if expected_repository is not None and repository.lower() != expected_repository.lower(): + raise SnapshotError( + f"snapshot repository {repository!r} differs from expected {expected_repository!r}" + ) + default_branch = require_ref(value["defaultBranch"], "defaultBranch") + if not isinstance(value["deleteBranchOnMerge"], bool): + raise SnapshotError("deleteBranchOnMerge must be a boolean") + + branches_value = value["branches"] + if not isinstance(branches_value, list) or len(branches_value) > MAX_ITEMS: + raise SnapshotError(f"branches must be an array with at most {MAX_ITEMS} items") + branches = [require_ref(item, f"branches[{index}]") for index, item in enumerate(branches_value)] + if len(branches) != len(set(branches)): + raise SnapshotError("branches must not contain duplicate names") + if default_branch not in branches: + raise SnapshotError("defaultBranch is missing from branches") + + pulls = parse_snapshot_pulls(value["openPullRequests"]) + return { + "repository": repository, + "defaultBranch": default_branch, + "deleteBranchOnMerge": value["deleteBranchOnMerge"], + "branches": branches, + "openPullRequests": pulls, + } + + +def evaluate(snapshot: dict[str, Any], focus_branch: str | None = None) -> list[Finding]: + repository = snapshot["repository"] + branch_set = set(snapshot["branches"]) + findings: list[Finding] = [] + if not snapshot["deleteBranchOnMerge"]: + findings.append(Finding( + code="GOV-BRANCH-LIFECYCLE-001", + severity="error", + message="GitHub automatic head-branch deletion after merge is disabled.", + remediation="Set repository delete_branch_on_merge to true.", + evidence={"repository": repository, "deleteBranchOnMerge": False}, + )) + + internal_heads = { + item["headRef"] + for item in snapshot["openPullRequests"] + if item["headRepository"] is not None + and item["headRepository"].lower() == repository.lower() + } + missing_heads = sorted(internal_heads - branch_set) + if missing_heads: + findings.append(Finding( + code="GOV-BRANCH-LIFECYCLE-003", + severity="error", + message="The branch lifecycle snapshot is missing, malformed or inconsistent.", + remediation="Re-acquire the snapshot and reobserve the open PR head branches; preserve refs while the observation is unresolved.", + evidence={"repository": repository, "missingInternalHeads": missing_heads}, + )) + + allowed = {snapshot["defaultBranch"], *internal_heads} + orphaned = sorted(branch_set - allowed) + if focus_branch is not None: + orphaned = [b for b in orphaned if b == focus_branch] + if orphaned: + findings.append(Finding( + code="GOV-BRANCH-LIFECYCLE-002", + severity="error", + message="Remote branches exist without ownership by an open pull request.", + remediation=( + "Observe the exact branch head and open/closed PR history; preserve unmerged work " + "and reconcile its intent with branch_intent_reconciliation.py before choosing " + "continued delivery or an explicitly authorized discard. Do not create an empty PR " + "or delete a branch merely to satisfy this check. Unknown evidence is not permission to discard." + ), + evidence={"repository": repository, "orphanedBranches": orphaned}, + )) + return sorted(findings) + + +def report_payload(findings: list[Finding]) -> dict[str, Any]: + return { + "schema": REPORT_SCHEMA, + "status": "passed" if not findings else "failed", + "summary": {"errors": len(findings), "warnings": 0, "findings": len(findings)}, + "findings": [asdict(item) for item in findings], + } + + +def render_text(payload: dict[str, Any]) -> str: + lines: list[str] = [] + for finding in payload["findings"]: + evidence = json.dumps(finding["evidence"], ensure_ascii=False, sort_keys=True, separators=(",", ":")) + lines.append(f"{finding['code']} ERROR: {finding['message']} [{evidence}]") + lines.append(f" remediation: {finding['remediation']}") + summary = payload["summary"] + label = "GOV-BRANCH-PASS" if payload["status"] == "passed" else "GOV-BRANCH-FAIL" + lines.append( + f"{label}: {payload['status']} ({summary['errors']} errors, {summary['warnings']} warnings)" + ) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--snapshot", required=True, type=Path) + parser.add_argument("--expected-repository") + parser.add_argument("--format", choices=("text", "json"), default="text") + parser.add_argument( + "--focus-branch", + help="When validating in the context of a pull request, evaluate orphan status specifically for this branch.", + ) + args = parser.parse_args(argv) + + expected_repository: str | None = None + if args.expected_repository is not None: + try: + expected_repository = require_repository(args.expected_repository, "expected repository") + except SnapshotError as error: + parser.error(str(error)) + + findings: list[Finding] + try: + with args.snapshot.open("r", encoding="utf-8") as handle: + raw = json.load(handle) + snapshot = parse_snapshot(raw, expected_repository) + findings = evaluate(snapshot, focus_branch=args.focus_branch) + except (OSError, json.JSONDecodeError, SnapshotError) as error: + findings = [Finding( + code="GOV-BRANCH-LIFECYCLE-003", + severity="error", + message="The branch lifecycle snapshot is missing, malformed or inconsistent.", + remediation="Re-acquire the snapshot from the protected GitHub workflow; preserve refs while the observation is unresolved.", + evidence={"reason": str(error)}, + )] + + payload = report_payload(findings) + if args.format == "json": + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))) + else: + print(render_text(payload)) + return 0 if payload["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/change-evaluation.schema.json b/.governance/change-evaluation.schema.json new file mode 100644 index 0000000..03d0111 --- /dev/null +++ b/.governance/change-evaluation.schema.json @@ -0,0 +1,357 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/change-evaluation.schema.json", + "title": "Canonical change evaluation contract", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "subject", + "contract", + "actors", + "changeSet", + "criteriaEvaluation", + "gates", + "dimensions", + "approval", + "findings", + "contribution", + "verdict", + "confidence", + "provenance" + ], + "properties": { + "schemaVersion": { "const": "t2c.change-evaluation/v1" }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "event", "baseSha", "headSha", "mergeBaseSha", "evaluatedAt"], + "properties": { + "repository": { "$ref": "#/$defs/repository" }, + "event": { "enum": ["commit", "push", "pull_request", "merge_group"] }, + "pullRequest": { "type": ["integer", "null"], "minimum": 1 }, + "baseSha": { "$ref": "#/$defs/sha" }, + "headSha": { "$ref": "#/$defs/sha" }, + "mergeBaseSha": { "$ref": "#/$defs/sha" }, + "evaluatedAt": { "type": "string", "format": "date-time" } + }, + "allOf": [ + { + "if": { + "properties": { "event": { "const": "pull_request" } }, + "required": ["event"] + }, + "then": { "required": ["pullRequest"] } + } + ] + }, + "contract": { + "type": "object", + "additionalProperties": false, + "required": [ + "ticket", + "workstream", + "criteria", + "intentHash", + "policyHash", + "manifestLockHash", + "approvalScopeHash" + ], + "properties": { + "ticket": { "type": "string", "pattern": "^ticket-[0-9]{3,}$" }, + "workstream": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "criteria": { + "type": "array", + "items": { "$ref": "#/$defs/criterion" }, + "minItems": 1, + "uniqueItems": true + }, + "intentHash": { "$ref": "#/$defs/digest" }, + "policyHash": { "$ref": "#/$defs/digest" }, + "manifestLockHash": { "$ref": "#/$defs/digest" }, + "approvalScopeHash": { "$ref": "#/$defs/digest" } + } + }, + "actors": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "role", "contributionTypes"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "role": { + "enum": [ + "author", + "last-push-author", + "implementation-assistant", + "reviewer", + "tester", + "decision-owner" + ] + }, + "contributionTypes": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + } + } + } + }, + "changeSet": { + "type": "object", + "additionalProperties": false, + "required": ["commits", "changedPaths", "changedSymbols", "publicApiChanges", "dependencyChanges"], + "properties": { + "commits": { + "type": "array", + "items": { "$ref": "#/$defs/sha" }, + "minItems": 1, + "uniqueItems": true + }, + "changedPaths": { + "type": "array", + "items": { "$ref": "#/$defs/path" }, + "minItems": 1, + "uniqueItems": true + }, + "changedSymbols": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "publicApiChanges": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "dependencyChanges": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + } + }, + "criteriaEvaluation": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "criterion", + "status", + "implementationEvidence", + "validationEvidence", + "missingEvidence", + "confidence" + ], + "properties": { + "criterion": { "$ref": "#/$defs/criterion" }, + "status": { "enum": ["SATISFIED", "PARTIAL", "FAILED", "UNKNOWN", "NOT_APPLICABLE"] }, + "implementationEvidence": { + "type": "array", + "items": { "$ref": "#/$defs/evidence" } + }, + "validationEvidence": { + "type": "array", + "items": { "$ref": "#/$defs/evidence" } + }, + "missingEvidence": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "SATISFIED" } }, "required": ["status"] }, + "then": { + "properties": { + "implementationEvidence": { "minItems": 1 }, + "validationEvidence": { "minItems": 1 }, + "missingEvidence": { "maxItems": 0 } + } + } + } + ] + } + }, + "gates": { + "type": "object", + "additionalProperties": false, + "required": [ + "governance", + "scope", + "secrets", + "tests", + "regression", + "documentation", + "approval", + "evidenceCompleteness" + ], + "properties": { + "governance": { "$ref": "#/$defs/gateStatus" }, + "scope": { "$ref": "#/$defs/gateStatus" }, + "secrets": { "$ref": "#/$defs/gateStatus" }, + "tests": { "$ref": "#/$defs/gateStatus" }, + "regression": { "$ref": "#/$defs/gateStatus" }, + "documentation": { "$ref": "#/$defs/gateStatus" }, + "approval": { "$ref": "#/$defs/gateStatus" }, + "evidenceCompleteness": { "$ref": "#/$defs/gateStatus" } + } + }, + "dimensions": { + "type": "object", + "additionalProperties": false, + "required": [ + "governanceCompliance", + "intentAlignment", + "implementationCorrectness", + "projectDirection", + "changeReasonableness", + "contributionValue", + "evidenceConfidence" + ], + "properties": { + "governanceCompliance": { "$ref": "#/$defs/dimensionStatus" }, + "intentAlignment": { "$ref": "#/$defs/dimensionStatus" }, + "implementationCorrectness": { "$ref": "#/$defs/dimensionStatus" }, + "projectDirection": { "$ref": "#/$defs/dimensionStatus" }, + "changeReasonableness": { "$ref": "#/$defs/dimensionStatus" }, + "contributionValue": { "$ref": "#/$defs/dimensionStatus" }, + "evidenceConfidence": { "$ref": "#/$defs/dimensionStatus" } + } + }, + "approval": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "source", + "actor", + "actorRole", + "verificationMethod", + "headSha", + "approvalScopeHash", + "evidenceDigest" + ], + "properties": { + "status": { "enum": ["VERIFIED", "WAITING", "REJECTED"] }, + "source": { "enum": ["github-review", "github-app-review", "signed-attestation", "none"] }, + "actor": { "type": "string", "minLength": 1 }, + "actorRole": { "enum": ["human", "validator-app", "attestation-issuer", "unresolved"] }, + "verificationMethod": { "enum": ["github-api-allowlist", "signed-attestation", "none"] }, + "headSha": { "oneOf": [{ "$ref": "#/$defs/sha" }, { "type": "null" }] }, + "approvalScopeHash": { "oneOf": [{ "$ref": "#/$defs/digest" }, { "type": "null" }] }, + "evidenceDigest": { "oneOf": [{ "$ref": "#/$defs/digest" }, { "type": "null" }] } + } + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "severity", "message", "evidence", "remediation", "blocks"], + "properties": { + "code": { "type": "string", "pattern": "^(GOV|COM|INT|EVD|REG|DIR|DOC|APR|CTR|SEC)-[A-Z0-9-]+$" }, + "severity": { "enum": ["BLOCKING", "REVIEW_REQUIRED", "INFO"] }, + "criterion": { "$ref": "#/$defs/criterion" }, + "message": { "type": "string", "minLength": 1 }, + "evidence": { "type": "array", "items": { "$ref": "#/$defs/evidence" }, "minItems": 1 }, + "remediation": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1 }, + "blocks": { + "type": "object", + "additionalProperties": false, + "required": ["merge", "completion"], + "properties": { + "merge": { "type": "boolean" }, + "completion": { "type": "boolean" } + } + } + } + } + }, + "contribution": { + "type": "object", + "additionalProperties": false, + "required": ["claims"], + "properties": { + "claims": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["actor", "type", "evidence"], + "properties": { + "actor": { "type": "string", "minLength": 1 }, + "type": { "enum": ["implementation", "test", "diagnosis", "review", "decision", "documentation"] }, + "criterion": { "$ref": "#/$defs/criterion" }, + "finding": { "type": "string", "minLength": 1 }, + "evidence": { "type": "array", "items": { "$ref": "#/$defs/evidence" }, "minItems": 1 } + } + } + } + } + }, + "verdict": { + "type": "object", + "additionalProperties": false, + "required": ["merge", "completion", "reasonCodes", "requiredHumanDecisions"], + "properties": { + "merge": { "enum": ["BLOCKED", "REVIEW_REQUIRED", "ALLOWED"] }, + "completion": { "enum": ["NOT_DONE", "CANDIDATE", "ACCEPTED"] }, + "reasonCodes": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "requiredHumanDecisions": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } + } + }, + "confidence": { + "type": "object", + "additionalProperties": false, + "required": ["overall", "unknowns"], + "properties": { + "overall": { "type": "number", "minimum": 0, "maximum": 1 }, + "unknowns": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["evaluatorVersion", "generatedByWorkflow"], + "properties": { + "evaluatorVersion": { "type": "string", "minLength": 1 }, + "generatedByWorkflow": { "type": "string", "minLength": 1 }, + "evaluationDigest": { "$ref": "#/$defs/digest" } + } + } + }, + "$defs": { + "sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" + }, + "criterion": { "type": "string", "pattern": "^AC-[0-9]+$" }, + "gateStatus": { "enum": ["PASS", "FAILED", "UNKNOWN", "WAITING", "NOT_APPLICABLE"] }, + "dimensionStatus": { "enum": ["PASS", "REVIEW_REQUIRED", "FAILED", "INSUFFICIENT_EVIDENCE", "NOT_APPLICABLE"] }, + "evidence": { + "type": "object", + "minProperties": 2, + "required": ["type"], + "properties": { + "type": { "type": "string", "minLength": 1 }, + "reference": { "type": "string", "minLength": 1 }, + "path": { "$ref": "#/$defs/path" }, + "symbol": { "type": "string", "minLength": 1 }, + "revision": { "$ref": "#/$defs/sha" }, + "result": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/.governance/change-lease.schema.json b/.governance/change-lease.schema.json new file mode 100644 index 0000000..ed14b28 --- /dev/null +++ b/.governance/change-lease.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.dev/schemas/change-lease/v1", + "title": "Wellmanifest multi-agent change lease", + "oneOf": [ + {"$ref": "#/$defs/lease"}, + {"$ref": "#/$defs/transition"}, + {"$ref": "#/$defs/receipt"} + ], + "$defs": { + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "gitSha": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "phase": {"enum": ["claimed", "editing", "validating", "publication_frozen", "dispatching", "approved", "merged", "closed", "cancelled", "expired", "released"]}, + "action": {"enum": ["heartbeat", "begin-edit", "begin-validation", "freeze-publication", "dispatch-validation", "approve", "record-merge", "close", "cancel", "expire", "release", "supersede"]}, + "lease": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "leaseId", "repositoryRef", "targetBranch", "ticketId", "workstream", "scopeHash", "branchRef", "worktreeId", "ownerActor", "ownerSession", "phase", "leaseRevision", "fencingToken", "issuedAt", "expiresAt", "heartbeatAt", "headSha", "pullRequest", "validatorRunId", "publicationFrozen", "planHash", "previousReceiptRef", "eventSequence"], + "properties": { + "schema": {"const": "wellmanifest.change-lease/v1"}, + "leaseId": {"type": "string", "minLength": 1, "maxLength": 160}, + "repositoryRef": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}, + "targetBranch": {"type": "string", "minLength": 1, "maxLength": 255}, + "ticketId": {"type": "string", "minLength": 1, "maxLength": 160}, + "workstream": {"type": "string", "minLength": 1, "maxLength": 160}, + "scopeHash": {"$ref": "#/$defs/sha256"}, + "branchRef": {"type": "string", "minLength": 1, "maxLength": 255}, + "worktreeId": {"type": "string", "minLength": 1, "maxLength": 255}, + "ownerActor": {"type": "string", "minLength": 1, "maxLength": 160}, + "ownerSession": {"type": "string", "minLength": 1, "maxLength": 255}, + "phase": {"$ref": "#/$defs/phase"}, + "leaseRevision": {"type": "integer", "minimum": 1}, + "fencingToken": {"type": "integer", "minimum": 1}, + "issuedAt": {"type": "string", "format": "date-time"}, + "expiresAt": {"type": "string", "format": "date-time"}, + "heartbeatAt": {"type": "string", "format": "date-time"}, + "headSha": {"oneOf": [{"$ref": "#/$defs/gitSha"}, {"type": "null"}]}, + "pullRequest": {"type": ["integer", "null"], "minimum": 1}, + "validatorRunId": {"type": ["string", "null"], "minLength": 1, "maxLength": 255}, + "publicationFrozen": {"type": "boolean"}, + "planHash": {"$ref": "#/$defs/sha256"}, + "previousReceiptRef": {"type": ["string", "null"], "maxLength": 512}, + "eventSequence": {"type": "integer", "minimum": 1} + } + }, + "transition": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "requestId", "leaseId", "action", "expectedRevision", "expectedFencingToken", "expectedPhase", "requestedBy", "idempotencyKey", "targetHeadSha", "replacementReceiptRef", "authorityRef", "requestedAt"], + "properties": { + "schema": {"const": "wellmanifest.change-lease-transition/v1"}, + "requestId": {"type": "string", "minLength": 1, "maxLength": 160}, + "leaseId": {"type": "string", "minLength": 1, "maxLength": 160}, + "action": {"$ref": "#/$defs/action"}, + "expectedRevision": {"type": "integer", "minimum": 1}, + "expectedFencingToken": {"type": "integer", "minimum": 1}, + "expectedPhase": {"$ref": "#/$defs/phase"}, + "requestedBy": {"type": "string", "minLength": 1, "maxLength": 160}, + "idempotencyKey": {"type": "string", "minLength": 1, "maxLength": 255}, + "targetHeadSha": {"oneOf": [{"$ref": "#/$defs/gitSha"}, {"type": "null"}]}, + "replacementReceiptRef": {"type": ["string", "null"], "maxLength": 512}, + "authorityRef": {"type": "string", "minLength": 1, "maxLength": 512}, + "requestedAt": {"type": "string", "format": "date-time"} + } + }, + "receipt": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "requestId", "leaseId", "previousRevision", "leaseRevision", "previousFencingToken", "fencingToken", "action", "outcome", "code", "phaseBefore", "phaseAfter", "headSha", "pullRequest", "receiptRef", "occurredAt"], + "properties": { + "schema": {"const": "wellmanifest.change-lease-receipt/v1"}, + "requestId": {"type": "string", "minLength": 1, "maxLength": 160}, + "leaseId": {"type": "string", "minLength": 1, "maxLength": 160}, + "previousRevision": {"type": "integer", "minimum": 1}, + "leaseRevision": {"type": "integer", "minimum": 1}, + "previousFencingToken": {"type": "integer", "minimum": 1}, + "fencingToken": {"type": "integer", "minimum": 1}, + "action": {"$ref": "#/$defs/action"}, + "outcome": {"enum": ["accepted", "rejected", "idempotent"]}, + "code": {"type": ["string", "null"]}, + "phaseBefore": {"$ref": "#/$defs/phase"}, + "phaseAfter": {"$ref": "#/$defs/phase"}, + "headSha": {"oneOf": [{"$ref": "#/$defs/gitSha"}, {"type": "null"}]}, + "pullRequest": {"type": ["integer", "null"], "minimum": 1}, + "receiptRef": {"type": "string", "minLength": 1, "maxLength": 512}, + "occurredAt": {"type": "string", "format": "date-time"} + } + } + } +} diff --git a/.governance/change_lease_check.py b/.governance/change_lease_check.py new file mode 100755 index 0000000..96ed62a --- /dev/null +++ b/.governance/change_lease_check.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +"""Validate multi-agent repository change leases without external dependencies.""" + +from __future__ import annotations + +import argparse +import json +import hashlib +import os +import stat +import subprocess +import re +import sys +from datetime import datetime, timezone +from functools import lru_cache +from pathlib import Path +from typing import Any + +LEASE_SCHEMA = "wellmanifest.change-lease/v1" +REQUEST_SCHEMA = "wellmanifest.change-lease-transition/v1" +RECEIPT_SCHEMA = "wellmanifest.change-lease-receipt/v1" +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +GIT_SHA_RE = re.compile(r"^[0-9a-f]{40,64}$") +PHASES = {"claimed", "editing", "validating", "publication_frozen", "dispatching", "approved", "merged", "closed", "cancelled", "expired", "released"} +FROZEN_PHASES = {"publication_frozen", "dispatching", "approved"} +TERMINAL_REPLACEMENT_PHASES = {"merged", "closed", "cancelled", "released"} +# wellmanifest/worktrees v5 layout: the single source for every lease and worktree reader. +WORKTREES_ROOT = ".worktrees" +LEASE_ROOT = ".subactor/leases" +MAX_LEASE_BYTES = 65536 +TRANSITIONS = { + "heartbeat": {phase: phase for phase in PHASES - {"released"}}, + "begin-edit": {"claimed": "editing"}, + "begin-validation": {"editing": "validating"}, + "freeze-publication": {"validating": "publication_frozen"}, + "dispatch-validation": {"publication_frozen": "dispatching"}, + "approve": {"dispatching": "approved"}, + "record-merge": {"approved": "merged"}, + "close": {"merged": "closed"}, + "cancel": {phase: "cancelled" for phase in PHASES - {"merged", "closed", "cancelled", "expired", "released"}}, + "expire": {phase: "expired" for phase in PHASES - {"merged", "closed", "cancelled", "expired", "released"}}, + "release": {"closed": "released", "cancelled": "released", "expired": "released"}, + "supersede": {"claimed": "cancelled", "editing": "cancelled", "validating": "cancelled"}, +} +LEASE_FIELDS = {"schema", "leaseId", "repositoryRef", "targetBranch", "ticketId", "workstream", "scopeHash", "branchRef", "worktreeId", "ownerActor", "ownerSession", "phase", "leaseRevision", "fencingToken", "issuedAt", "expiresAt", "heartbeatAt", "headSha", "pullRequest", "validatorRunId", "publicationFrozen", "planHash", "previousReceiptRef", "eventSequence"} +REQUEST_FIELDS = {"schema", "requestId", "leaseId", "action", "expectedRevision", "expectedFencingToken", "expectedPhase", "requestedBy", "idempotencyKey", "targetHeadSha", "replacementReceiptRef", "authorityRef", "requestedAt"} +RECEIPT_FIELDS = {"schema", "requestId", "leaseId", "previousRevision", "leaseRevision", "previousFencingToken", "fencingToken", "action", "outcome", "code", "phaseBefore", "phaseAfter", "headSha", "pullRequest", "receiptRef", "occurredAt"} + + +def finding(code: str, message: str, **evidence: Any) -> dict[str, Any]: + return {"code": code, "message": message, "evidence": evidence} + + +def load_json(path: Path) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + return None, [finding("GOV-CHANGE-LEASE-001", f"Cannot read JSON document: {error}", path=str(path))] + if not isinstance(value, dict): + return None, [finding("GOV-CHANGE-LEASE-001", "Document root must be an object.", path=str(path))] + return value, [] + + +def closed_object(value: dict[str, Any], expected: set[str], label: str) -> list[dict[str, Any]]: + missing, extra = sorted(expected - set(value)), sorted(set(value) - expected) + return [] if not missing and not extra else [finding("GOV-CHANGE-LEASE-001", f"{label} is not a closed object.", missing=missing, extra=extra)] + + +def valid_time(value: Any) -> bool: + if not isinstance(value, str) or not re.fullmatch( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-9]{2}" + r"(?:\.[0-9]+)?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})", value + ): + return False + try: + timestamp = datetime.fromisoformat(value.replace("Z", "+00:00").replace("z", "+00:00")) + except ValueError: + return False + return timestamp.utcoffset() is not None + + +@lru_cache(maxsize=1) +def lease_schema() -> dict[str, Any]: + directory = Path(__file__).resolve().parent + for path in (directory / "change-lease.schema.json", + directory.parent / "subprojects/change-lease/change-lease.schema.json"): + if path.is_symlink(): + raise ValueError("Canonical lease schema must be a regular managed file.") + if path.is_file(): + if path.stat().st_size > 65536: + raise ValueError("Canonical lease schema exceeds its bounded size.") + schema = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(schema, dict) or not isinstance(schema.get("$defs"), dict): + raise ValueError("Canonical lease schema has no definitions.") + return schema + raise ValueError("Canonical lease schema is missing; restore the managed package.") + + +def scalar_shape_matches(value: Any, shape: dict[str, Any], definitions: dict[str, Any], depth: int = 0) -> bool: + """Match only the bounded scalar vocabulary used by this pinned contract. + + This is not a general JSON Schema engine. Unknown constraints fail closed. + Semantic publication/CAS checks still run separately after shape validation. + """ + supported = {"$ref", "type", "enum", "const", "minLength", "maxLength", + "minimum", "pattern", "format", "oneOf"} + if depth > 16 or not isinstance(shape, dict) or set(shape) - supported: + return False + if "$ref" in shape: + reference = shape["$ref"] + if not isinstance(reference, str) or not reference.startswith("#/$defs/"): + return False + target = definitions.get(reference[len("#/$defs/"):]) + if not scalar_shape_matches(value, target, definitions, depth + 1): + return False + if "oneOf" in shape: + choices = shape["oneOf"] + if not isinstance(choices, list) or sum( + scalar_shape_matches(value, choice, definitions, depth + 1) for choice in choices + ) != 1: + return False + if "type" in shape: + types = shape["type"] if isinstance(shape["type"], list) else [shape["type"]] + matches = {"string": isinstance(value, str), "integer": type(value) is int, + "boolean": type(value) is bool, "null": value is None} + if any(kind not in matches for kind in types) or not any(matches[kind] for kind in types): + return False + if "const" in shape and value != shape["const"]: + return False + if "enum" in shape and value not in shape["enum"]: + return False + if value is None: + return True + for key, compare in (("minLength", lambda a, b: a >= b), ("maxLength", lambda a, b: a <= b)): + if key in shape and (not isinstance(value, str) or not compare(len(value), shape[key])): + return False + if "minimum" in shape and (type(value) is not int or value < shape["minimum"]): + return False + if "pattern" in shape and (not isinstance(value, str) or not re.fullmatch(shape["pattern"], value)): + return False + if "format" in shape and (shape["format"] != "date-time" or not valid_time(value)): + return False + return True + + +def schema_shape_errors(value: dict[str, Any], definition: str) -> list[dict[str, Any]]: + try: + definitions = lease_schema()["$defs"] + shape = definitions[definition] + if (shape.get("type") != "object" or shape.get("additionalProperties") is not False + or set(shape) - {"type", "additionalProperties", "required", "properties"}): + raise ValueError("Unsupported canonical document shape.") + required, properties = set(shape["required"]), shape["properties"] + if not required.issubset(value) or not set(value).issubset(properties): + return [finding("GOV-CHANGE-LEASE-001", "Document fields differ from the canonical schema.")] + return [finding("GOV-CHANGE-LEASE-001", f"{key} does not satisfy its canonical scalar contract.") + for key, item in value.items() if not scalar_shape_matches(item, properties[key], definitions)] + except (OSError, UnicodeError, ValueError, KeyError, TypeError) as error: + return [finding("GOV-CHANGE-LEASE-001", f"Cannot validate canonical document shape: {error}")] + + +def validate_publication_lease_fields(value, phase, errors) -> None: + head, pr = value.get("headSha"), value.get("pullRequest") + if head is not None and (not isinstance(head, str) or not GIT_SHA_RE.fullmatch(head)): + errors.append(finding("GOV-CHANGE-LEASE-001", "headSha must be null or an exact Git SHA.")) + if pr is not None and (not isinstance(pr, int) or pr < 1): + errors.append(finding("GOV-CHANGE-LEASE-001", "pullRequest must be null or a positive integer.")) + frozen = value.get("publicationFrozen") + if not isinstance(frozen, bool) or frozen != (phase in FROZEN_PHASES): + errors.append(finding("GOV-CHANGE-LEASE-003", "publicationFrozen must match frozen phases.", phase=phase, publicationFrozen=frozen)) + if phase in FROZEN_PHASES | {"merged", "closed", "released"} and head is None: + errors.append(finding("GOV-CHANGE-LEASE-003", "Publication and merge terminal phases require headSha.", phase=phase)) + + +def validate_lease(value: dict[str, Any]) -> list[dict[str, Any]]: + errors = closed_object(value, LEASE_FIELDS, "lease") or schema_shape_errors(value, "lease") + if errors: + return errors + strings = ["leaseId", "repositoryRef", "targetBranch", "ticketId", "workstream", "branchRef", "worktreeId", "ownerActor", "ownerSession"] + if value.get("schema") != LEASE_SCHEMA or any(not isinstance(value.get(k), str) or not value[k] for k in strings): + errors.append(finding("GOV-CHANGE-LEASE-001", "Lease identity fields are invalid.")) + if not SHA256_RE.fullmatch(str(value.get("scopeHash", ""))) or not SHA256_RE.fullmatch(str(value.get("planHash", ""))): + errors.append(finding("GOV-CHANGE-LEASE-001", "Lease hashes must be lowercase SHA-256 values.")) + phase = value.get("phase") + if phase not in PHASES: + errors.append(finding("GOV-CHANGE-LEASE-001", "Lease phase is invalid.", phase=phase)) + for key in ("leaseRevision", "fencingToken", "eventSequence"): + if not isinstance(value.get(key), int) or value[key] < 1: + errors.append(finding("GOV-CHANGE-LEASE-001", f"{key} must be a positive integer.")) + for key in ("issuedAt", "expiresAt", "heartbeatAt"): + if not valid_time(value.get(key)): + errors.append(finding("GOV-CHANGE-LEASE-001", f"{key} must be an RFC 3339 timestamp.")) + validate_publication_lease_fields(value, phase, errors) + return errors + + +def validate_request(value: dict[str, Any]) -> list[dict[str, Any]]: + errors = closed_object(value, REQUEST_FIELDS, "transition request") or schema_shape_errors(value, "transition") + if errors: + return errors + if value.get("schema") != REQUEST_SCHEMA or value.get("action") not in TRANSITIONS: + errors.append(finding("GOV-CHANGE-LEASE-001", "Transition schema or action is invalid.")) + for key in ("requestId", "leaseId", "requestedBy", "idempotencyKey", "authorityRef"): + if not isinstance(value.get(key), str) or not value[key]: + errors.append(finding("GOV-CHANGE-LEASE-001", f"{key} must be a non-empty string.")) + for key in ("expectedRevision", "expectedFencingToken"): + if not isinstance(value.get(key), int) or value[key] < 1: + errors.append(finding("GOV-CHANGE-LEASE-001", f"{key} must be a positive integer.")) + if value.get("expectedPhase") not in PHASES or not valid_time(value.get("requestedAt")): + errors.append(finding("GOV-CHANGE-LEASE-001", "Expected phase or timestamp is invalid.")) + head = value.get("targetHeadSha") + if head is not None and (not isinstance(head, str) or not GIT_SHA_RE.fullmatch(head)): + errors.append(finding("GOV-CHANGE-LEASE-001", "targetHeadSha must be null or an exact Git SHA.")) + return errors + + +def validate_receipt(value: dict[str, Any]) -> list[dict[str, Any]]: + errors = closed_object(value, RECEIPT_FIELDS, "transition receipt") or schema_shape_errors(value, "receipt") + if errors: + return errors + if value.get("schema") != RECEIPT_SCHEMA or value.get("action") not in TRANSITIONS or value.get("outcome") not in {"accepted", "rejected", "idempotent"}: + errors.append(finding("GOV-CHANGE-LEASE-001", "Receipt schema, action or outcome is invalid.")) + if value.get("phaseBefore") not in PHASES or value.get("phaseAfter") not in PHASES: + errors.append(finding("GOV-CHANGE-LEASE-001", "Receipt phase is invalid.")) + for key in ("previousRevision", "leaseRevision", "previousFencingToken", "fencingToken"): + if not isinstance(value.get(key), int) or value[key] < 1: + errors.append(finding("GOV-CHANGE-LEASE-001", f"{key} must be a positive integer.")) + if value.get("outcome") == "accepted" and (value["leaseRevision"] != value["previousRevision"] + 1 or value["fencingToken"] != value["previousFencingToken"] + 1): + errors.append(finding("GOV-CHANGE-LEASE-002", "Accepted receipt must increment revision and fencing token exactly once.")) + if value["outcome"] == "rejected" and ( + value["leaseRevision"] != value["previousRevision"] + or value["fencingToken"] != value["previousFencingToken"] + or value["phaseAfter"] != value["phaseBefore"]): + errors.append(finding("GOV-CHANGE-LEASE-002", "Rejected receipt must not advance state.")) + if not valid_time(value.get("occurredAt")): + errors.append(finding("GOV-CHANGE-LEASE-001", "Receipt timestamp is invalid.")) + return errors + + +def transition_identity_error(lease, request, phase, errors): + code = message = None + if not errors and request["leaseId"] != lease["leaseId"]: + code, message = "GOV-CHANGE-LEASE-002", "Request targets another lease." + if not errors and (request["expectedRevision"] != lease["leaseRevision"] or request["expectedFencingToken"] != lease["fencingToken"] or request["expectedPhase"] != phase): + code, message = "GOV-CHANGE-LEASE-002", "Compare-and-swap authority is stale." + return code, message + + +def transition_phase_error(lease, request, phase, action, next_phase, target_head, replacement): + if next_phase is None: + return "GOV-CHANGE-LEASE-003", "Transition is not allowed from the current phase." + if action == "freeze-publication" and target_head is None: + return "GOV-CHANGE-LEASE-003", "Freeze requires an exact targetHeadSha." + if phase in FROZEN_PHASES and target_head not in (None, lease.get("headSha")): + return "GOV-CHANGE-LEASE-003", "Frozen publication head cannot be changed." + if action in {"dispatch-validation", "approve", "record-merge"} and target_head != lease.get("headSha"): + return "GOV-CHANGE-LEASE-003", "Transition requires the exact frozen head." + if action == "supersede": + return replacement_receipt_error(request, replacement) + return None, None + + +def transition_receipt(lease, request, phase, action, next_phase, target_head, code): + accepted = code is None + return { + "schema": RECEIPT_SCHEMA, "requestId": request.get("requestId", "invalid"), "leaseId": lease.get("leaseId", "invalid"), + "previousRevision": lease.get("leaseRevision", 1), "leaseRevision": lease.get("leaseRevision", 1) + int(accepted), + "previousFencingToken": lease.get("fencingToken", 1), "fencingToken": lease.get("fencingToken", 1) + int(accepted), + "action": action if action in TRANSITIONS else "heartbeat", "outcome": "accepted" if accepted else "rejected", "code": code, + "phaseBefore": phase if phase in PHASES else "claimed", "phaseAfter": next_phase if accepted and next_phase else (phase if phase in PHASES else "claimed"), + "headSha": target_head if accepted and action == "freeze-publication" else lease.get("headSha"), "pullRequest": lease.get("pullRequest"), + "receiptRef": f"receipt://change-lease/{lease.get('leaseId', 'invalid')}/{request.get('requestId', 'invalid')}", "occurredAt": request.get("requestedAt", "1970-01-01T00:00:00Z"), + } + + +def replacement_receipt_error(request, replacement): + invalid = replacement is None or bool(validate_receipt(replacement)) or replacement.get("phaseAfter") not in TERMINAL_REPLACEMENT_PHASES or replacement.get("outcome") != "accepted" + if invalid: + return "GOV-CHANGE-LEASE-004", "Supersede requires an accepted terminal replacement receipt." + elif request.get("replacementReceiptRef") != replacement.get("receiptRef"): + return "GOV-CHANGE-LEASE-004", "Replacement receipt reference does not match." + return None, None + + +def evaluate_transition(lease: dict[str, Any], request: dict[str, Any], replacement: dict[str, Any] | None = None) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + errors = validate_lease(lease) + validate_request(request) + if errors: + # Invalid inputs cannot identify a trustworthy state to receipt. + return None, errors + phase, action = str(lease.get("phase", "claimed")), request.get("action") + code, message = transition_identity_error(lease, request, phase, errors) + next_phase = TRANSITIONS.get(str(action), {}).get(phase) + target_head = request.get("targetHeadSha") + if not errors and code is None: + code, message = transition_phase_error(lease, request, phase, action, next_phase, target_head, replacement) + if errors: + code, message = errors[0]["code"], errors[0]["message"] + accepted = code is None + receipt = transition_receipt(lease, request, phase, action, next_phase, target_head, code) + return receipt, errors or ([] if accepted else [finding(str(code), str(message), phase=phase, action=action)]) + + +def validate_trace(path: Path) -> list[dict[str, Any]]: + findings, previous = [], None + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as error: + return [finding("GOV-CHANGE-LEASE-001", f"Cannot read receipt trace: {error}", path=str(path))] + for number, line in enumerate(lines, 1): + if not line.strip(): + continue + try: + receipt = json.loads(line) + except json.JSONDecodeError as error: + findings.append(finding("GOV-CHANGE-LEASE-001", f"Invalid receipt JSON: {error}", line=number)); continue + if not isinstance(receipt, dict): + findings.append(finding("GOV-CHANGE-LEASE-001", "Receipt must be an object.", line=number)); continue + errors = validate_receipt(receipt) + findings.extend(errors) + if errors: + continue + if previous is not None and receipt.get("outcome") in {"accepted", "rejected"} and (receipt.get("leaseId") != previous.get("leaseId") or receipt.get("previousRevision") != previous.get("leaseRevision") or receipt.get("previousFencingToken") != previous.get("fencingToken") or receipt.get("phaseBefore") != previous.get("phaseAfter")): + findings.append(finding("GOV-CHANGE-LEASE-002", "Receipt trace is not monotonic.", line=number)) + if receipt.get("outcome") == "accepted": + previous = receipt + return findings + + +def validate_document(path: Path) -> list[dict[str, Any]]: + value, errors = load_json(path) + if value is None: + return errors + validators = {LEASE_SCHEMA: validate_lease, REQUEST_SCHEMA: validate_request, RECEIPT_SCHEMA: validate_receipt} + validator = validators.get(value.get("schema")) + return validator(value) if validator else [finding("GOV-CHANGE-LEASE-001", "Unknown change-lease schema.", schema=value.get("schema"))] + + +def validate_repository(root: Path) -> list[dict[str, Any]]: + findings = [] + lease, trace = root / ".governance/change-lease.json", root / ".governance/change-lease-events.jsonl" + if lease.exists(): findings.extend(validate_document(lease)) + if trace.exists(): findings.extend(validate_trace(trace)) + return findings + + +def print_findings(findings: list[dict[str, Any]], output_format: str) -> None: + if output_format == "json": + print(json.dumps({"status": "failed" if findings else "passed", "findings": findings}, indent=2, sort_keys=True)); return + if not findings: + print("GOV-CHANGE-LEASE-PASS"); return + for item in findings: print(f"{item['code']} ERROR: {item['message']}") + + +TICKET_REF = re.compile(r"^refs/heads/ticket[-/]([0-9]{3,})-[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def read_writer_lease(path: Path) -> dict[str, Any]: + """Open bounded regular data without following any symlink component.""" + if os.name == "nt": + if path.is_symlink() or any(p.is_symlink() for p in path.parents): + raise ValueError("Lease path contains symlinks") + if not path.is_file(): + raise ValueError("Lease must be a regular file") + raw = path.read_bytes() + if len(raw) > MAX_LEASE_BYTES: + raise ValueError("Lease exceeds bounded size") + value = json.loads(raw.decode("utf-8")) + if not isinstance(value, dict): + raise ValueError("Lease must be an object") + return value + flags = os.O_RDONLY | os.O_NOFOLLOW + descriptor = os.open(path.anchor, flags | os.O_DIRECTORY) + try: + for component in path.parts[1:-1]: + child = os.open(component, flags | os.O_DIRECTORY, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + leaf = os.open(path.name, flags | os.O_NONBLOCK, dir_fd=descriptor) + with os.fdopen(leaf, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ValueError("Lease must be a regular file") + raw = stream.read(MAX_LEASE_BYTES + 1) + if len(raw) > MAX_LEASE_BYTES: + raise ValueError("Lease exceeds bounded size") + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("Lease must be an object") + return value + finally: + os.close(descriptor) + + +def check_writer(root: Path, ref: str, operation: str, actor: str, session: str, + revision: str, fencing: str) -> list[dict[str, Any]]: + """Read the canonical controller projection; never renew or accept handoffs. + + Identity and CAS values must come from the invoking session, not this file. + Local hooks defend against accidental competing writers, not a hostile OS user. + """ + match = TICKET_REF.fullmatch(ref) + if not match: + return [finding("GOV-CHANGE-LEASE-001", "Writer check requires an exact ticket branch ref.")] + if (not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:@/-]{0,159}", actor) + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:@/-]{0,254}", session) + or not re.fullmatch(r"[1-9][0-9]{0,18}", revision) + or not re.fullmatch(r"[1-9][0-9]{0,18}", fencing)): + return [finding("GOV-CHANGE-LEASE-001", "Supply writer actor/session and current lease revision/fencing from the controller.")] + try: + # Git hook variables may name another checkout's index/git-dir. Discovery + # must be relative to the explicitly supplied root. + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + def git(*args: str) -> str: + return subprocess.run(["git", "-C", str(root), *args], env=env, + check=True, capture_output=True, text=True).stdout.strip() + rows = git("worktree", "list", "--porcelain", "-z").split("\0\0") + records = [dict(field.split(" ", 1) for field in row.split("\0") if " " in field) + for row in rows if row] + primary = Path(records[0]["worktree"]).resolve() + owners = [row for row in records if row.get("branch") == ref] + if len(owners) != 1: + raise ValueError("Ticket ref needs exactly one registered canonical worktree") + ticket = "ticket-" + match[1] + slug = ref.split(match[1] + "-", 1)[1] + worktree_id = ticket + "--" + slug + expected = primary / WORKTREES_ROOT / worktree_id + owner_worktree = Path(owners[0]["worktree"]) + if os.name == "nt": + if owner_worktree.resolve() != expected.resolve() or expected.is_symlink(): + raise ValueError("Ticket worktree is not canonical") + if operation == "commit" and root.resolve() != expected.resolve(): + raise ValueError("Commit must run in the owned worktree") + else: + if owner_worktree != expected or expected.resolve() != expected: + raise ValueError("Ticket worktree is not canonical") + if operation == "commit" and root.resolve() != expected: + raise ValueError("Commit must run in the owned worktree") + lease = read_writer_lease(primary / LEASE_ROOT / (worktree_id + ".json")) + errors = validate_lease(lease) + if errors: + return errors + repository = "local/" + hashlib.sha256(str(primary).encode("utf-8")).hexdigest() + if (lease["branchRef"] != ref or lease["ticketId"] != ticket + or lease["worktreeId"] != worktree_id or lease["repositoryRef"] != repository): + raise ValueError("Lease repository, ticket, branch or worktree binding differs") + if (lease["ownerActor"] != actor or lease["ownerSession"] != session + or lease["leaseRevision"] != int(revision) or lease["fencingToken"] != int(fencing)): + return [finding("GOV-CHANGE-LEASE-002", "Writer/session or controller fencing differs; use GOV-WORK-START accepted handoff, never copy the holder identity.")] + now = datetime.now(timezone.utc) + times = [datetime.fromisoformat(lease[key].replace("Z", "+00:00").replace("z", "+00:00")) + for key in ("issuedAt", "heartbeatAt", "expiresAt")] + if not times[0] <= times[1] <= now < times[2]: + return [finding("GOV-CHANGE-LEASE-002", "Lease is expired or its clock bounds are invalid; timeout is not a handoff.")] + if lease["phase"] not in {"claimed", "editing", "validating"} or lease["publicationFrozen"]: + return [finding("GOV-CHANGE-LEASE-003", "Lease phase forbids Git mutation; preserve the frozen or released writer state.")] + except (OSError, ValueError, KeyError, IndexError, AttributeError, subprocess.SubprocessError): + return [finding("GOV-CHANGE-LEASE-001", "Cannot verify canonical writer lease; restore controller evidence without taking ownership.")] + return [] + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--format", choices=("text", "json"), default="text") + sub = parser.add_subparsers(dest="command", required=True) + validate = sub.add_parser("validate"); validate.add_argument("document", type=Path) + transition = sub.add_parser("transition"); transition.add_argument("--lease", type=Path, required=True); transition.add_argument("--request", type=Path, required=True); transition.add_argument("--replacement-receipt", type=Path) + trace = sub.add_parser("trace"); trace.add_argument("document", type=Path) + repository = sub.add_parser("repository"); repository.add_argument("root", type=Path) + writer = sub.add_parser("writer-check") + writer.add_argument("--root", type=Path, required=True) + writer.add_argument("--ref", required=True) + writer.add_argument("--operation", choices=("commit", "push"), required=True) + for name in ("actor", "session", "revision", "fencing"): + variable = {"actor": "WRITER_ACTOR", "session": "WRITER_SESSION", + "revision": "LEASE_REVISION", "fencing": "FENCING_TOKEN"}[name] + writer.add_argument("--" + name, default=os.environ.get("NEW_PROJECT_" + variable, "")) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + if args.command == "writer-check": + findings = check_writer(args.root, args.ref, args.operation, args.actor, args.session, + args.revision, args.fencing) + elif args.command == "validate": findings = validate_document(args.document) + elif args.command == "trace": findings = validate_trace(args.document) + elif args.command == "repository": findings = validate_repository(args.root.resolve()) + else: + lease, left = load_json(args.lease); request, right = load_json(args.request); replacement, repl = None, [] + if args.replacement_receipt: replacement, repl = load_json(args.replacement_receipt) + if lease is None or request is None or repl: + print_findings(left + right + repl, args.format); return 1 + receipt, findings = evaluate_transition(lease, request, replacement) + if receipt is None: + print_findings(findings, args.format) + return 1 + print(json.dumps(receipt, indent=2, sort_keys=True)); return 1 if findings else 0 + print_findings(findings, args.format); return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/check_required_checks.py b/.governance/check_required_checks.py new file mode 100755 index 0000000..7a04cd6 --- /dev/null +++ b/.governance/check_required_checks.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Compare required-checks.json to jobs published by CI workflows. + +Single source of truth for required check *names* is the repository instance +of required-checks.json (hub: governance/, adopter: .governance/). This gate +fails when a required name is missing from the workflow that publishes it, or +when that workflow publishes a job that is not declared. + +Published names are the job ``name:`` field when present, otherwise the job +key. GitHub rulesets require the display name. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from collections import defaultdict +from pathlib import Path + +SCHEMA = "new-project.required-checks/v1" +JOB_LINE = re.compile(r"^ ([A-Za-z0-9][A-Za-z0-9_-]*):\s*(?:#.*)?$") +JOB_NAME_LINE = re.compile(r"^ name:\s*(.+?)\s*$") + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def yaml_scalar(raw: str) -> str: + value = raw.strip() + if value.startswith('"'): + try: + decoded, end = json.JSONDecoder().raw_decode(value) + except json.JSONDecodeError as exc: + raise SystemExit('unsupported double-quoted workflow scalar') from exc + if value[end:].strip() and not value[end:].lstrip().startswith('#'): + raise SystemExit('invalid trailing workflow scalar content') + return decoded + if value.startswith("'"): + match = re.fullmatch(r"'((?:[^']|'')*)'\s*(?:#.*)?", value) + if match is None: + raise SystemExit('unsupported single-quoted workflow scalar') + return match.group(1).replace("''", "'") + if " #" in value: + value = value.split(" #", 1)[0].rstrip() + return value + + +def resolve_source(root: Path, script_path: Path) -> tuple[Path | None, list[Path]]: + candidates: list[Path] = [] + for raw in ( + script_path.resolve().parent / "required-checks.json", + root / "governance" / "required-checks.json", + root / ".governance" / "required-checks.json", + ): + resolved = raw.resolve() + if resolved not in candidates: + candidates.append(resolved) + for path in candidates: + if path.is_file(): + return path, candidates + return None, candidates + + +def bound_check_pairs(required_checks: list) -> list[tuple[str, str]]: + pairs: list[tuple[str, str]] = [] + for item in required_checks: + if not isinstance(item, dict): + raise SystemExit("requiredChecks entries must be objects") + name = item.get("name") + workflow = item.get("workflowFile") + if not isinstance(name, str) or not name.strip(): + raise SystemExit("requiredChecks.name missing or empty") + if not isinstance(workflow, str) or not workflow.strip(): + raise SystemExit("requiredChecks.workflowFile missing or empty") + pairs.append((name, workflow)) + return pairs + + +def declared_checks(data: dict) -> list[tuple[str, str]]: + has_legacy = "workflowFile" in data or "requiredCheckNames" in data + has_bound = "requiredChecks" in data + if has_legacy and has_bound: + raise SystemExit( + "required-checks must declare exactly one shape: " + "workflowFile+requiredCheckNames or requiredChecks" + ) + required_checks = data.get("requiredChecks") + if isinstance(required_checks, list) and required_checks: + return bound_check_pairs(required_checks) + names = data.get("requiredCheckNames") + workflow = data.get("workflowFile") + if not isinstance(names, list) or not names or not all(isinstance(n, str) and n.strip() for n in names): + raise SystemExit("requiredCheckNames missing or empty") + if not isinstance(workflow, str) or not workflow.strip(): + raise SystemExit("workflowFile missing") + return [(name, workflow) for name in names] + + +def load_source(path: Path) -> dict: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict) or data.get("schema") != SCHEMA: + raise SystemExit(f"unsupported required-checks schema in {path}") + declared_checks(data) + return data + + +def workflow_job_names(workflow_path: Path, root: Path | None = None) -> list[str]: + text = workflow_path.read_text(encoding="utf-8") + active = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('#')) + if re.search(r'^ (?:uses:|[\'\"]uses[\'\"]:)|^ [A-Za-z0-9][A-Za-z0-9_-]*:\s*\{.*\buses\b', active, re.M): + # Both scripts are managed package members. Direct-job-only fixtures + # keep their standalone checker behavior without requiring this import. + import importlib.util + generator_path = Path(__file__).resolve().with_name('generate_required_checks.py') + spec = importlib.util.spec_from_file_location('required_checks_resolver', generator_path) + if spec is None or spec.loader is None: + raise SystemExit('managed reusable workflow resolver is missing') + generator = importlib.util.module_from_spec(spec) + spec.loader.exec_module(generator) + callers: list[str] = [] + names = generator.resolved_checks_text( + text, generator.reusable_sources(root or workflow_path.parents[2]), callers) + if callers: + raise SystemExit('unresolved reusable workflow callers: ' + ', '.join(callers)) + return names + lines = text.splitlines() + in_jobs = False + jobs: list[str] = [] + current_key: str | None = None + current_name: str | None = None + + def flush() -> None: + nonlocal current_key, current_name + if current_key is None: + return + jobs.append(current_name or current_key) + current_key = None + current_name = None + + for line in lines: + if re.match(r"^jobs:\s*(?:#.*)?$", line): + in_jobs = True + continue + if not in_jobs: + continue + if line and not line.startswith(" ") and not line.startswith("\t") and line.strip() and not line.lstrip().startswith("#"): + break + match = JOB_LINE.match(line) + if match: + flush() + current_key = match.group(1) + continue + name_match = JOB_NAME_LINE.match(line) + if name_match and current_key is not None and current_name is None: + current_name = yaml_scalar(name_match.group(1)) + flush() + if not jobs: + raise SystemExit(f"no jobs parsed from {workflow_path}") + return jobs + + +def compare(required: list[str], published: list[str]) -> list[str]: + errors: list[str] = [] + req_set = set(required) + pub_set = set(published) + for name in required: + if name not in pub_set: + errors.append( + f"required check {name!r} is missing from workflow jobs " + f"(published={sorted(pub_set)})" + ) + for name in published: + if name not in req_set: + errors.append( + f"workflow job {name!r} is not listed in requiredCheckNames " + f"(required={required})" + ) + if len(required) != len(set(required)): + errors.append(f"requiredCheckNames contains duplicates: {required}") + if len(published) != len(set(published)): + errors.append(f"workflow jobs contain duplicates: {published}") + return errors + + +UNRESOLVED_ADOPTER = "unresolved/adopter" +REMOTE_IDENTITY = re.compile( + r"(?:git@|https://|ssh://git@)(?:[^/:]+)[/:]([^/]+)/(.+?)(?:\.git)?/?$" +) + + +def own_identity(root: Path) -> str | None: + """Resolve owner/name for this checkout from Git, then from CI. + + The origin remote is what every other participant addresses this + repository by, so it outranks the directory name, which is a local + convenience. GITHUB_REPOSITORY is the fallback for a CI checkout whose + remote was not configured. + """ + try: + completed = subprocess.run( + ["git", "-C", str(root), "remote", "get-url", "origin"], + capture_output=True, text=True, check=False, + ) + except OSError: + completed = None + if completed is not None and completed.returncode == 0: + match = REMOTE_IDENTITY.match(completed.stdout.strip()) + if match: + return f"{match.group(1)}/{match.group(2)}" + # GITHUB_REPOSITORY names the checkout the workflow runs on, so it answers + # for that checkout and nothing else. Pointing the gate at a fixture or a + # second repository must not inherit the workflow's identity and report a + # mismatch that does not exist. + workspace = os.environ.get("GITHUB_WORKSPACE") + if workspace and Path(workspace).resolve() == root.resolve(): + return os.environ.get("GITHUB_REPOSITORY") or None + return None + + +def check_instance_identity(root: Path, data: dict, source_path: Path) -> list[str]: + """Refuse an instance that describes a different repository. + + required-checks.json is per-repository instance data seeded from a + template. A seed kept verbatim names the repository it came from, so its + check names are that repository's job names and can never turn green here. + Measured on 2026-09-09 across 88 governed checkouts: 30 declared another + repository and 22 required a check no local workflow publishes. + + Silence here is not neutral. The declared names are what a branch ruleset + enforces, so an unadapted instance blocks every pull request in the + repository while looking configured. + """ + declared = data.get("repository") + if not isinstance(declared, str) or not declared: + return [] + own = own_identity(root) + if declared == UNRESOLVED_ADOPTER: + # The managed updater is the only writer of the adoption lock, so the + # remedy is a rerun that binds the detected name, never a manual edit. + detected = (f"detected {own!r}; rerun" if own else + "no origin remote detected; add it or pass --repository OWNER/NAME and rerun") + return [ + f"{source_path} still carries the unadapted template identity " + f"{UNRESOLVED_ADOPTER!r}; {detected} the managed updater " + "(create_adoption_lock.py --target-root . --source-revision --upgrade) to bind it to the repository's own workflow checks" + ] + if own is None or declared.casefold() == own.casefold(): + return [] + return [ + f"{source_path} declares repository {declared!r} but this checkout is " + f"{own!r}; the instance was seeded and never adapted, so its check names " + "are another repository's job names" + ] + + +def source_for_check(root: Path, source_override: Path | None) -> Path | None: + looked: list[Path] = [] + if source_override is not None: + source_path = source_override + if not source_path.is_file(): + print( + "required-checks gate FAILED: source file not found. looked in:\n" + f" - {source_path}", + file=sys.stderr, + ) + return None + else: + source_path, looked = resolve_source(root, Path(__file__)) + if source_path is None: + print( + "required-checks gate FAILED: source file not found. looked in:", + file=sys.stderr, + ) + for path in looked: + print(f" - {path}", file=sys.stderr) + return None + return source_path + + +def print_check_success(root, source_path, required_names, published_names) -> None: + try: + source_label = source_path.relative_to(root) + except ValueError: + source_label = source_path + print( + "required-checks gate OK: " + f"source={source_label} " + f"required={required_names} published={published_names}" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + type=Path, + default=None, + help="repository root (default: parent of this script)", + ) + parser.add_argument( + "--source", + type=Path, + default=None, + help="override path to required-checks.json", + ) + parser.add_argument( + "--workflow", + type=Path, + default=None, + help="override path to a single workflow YAML", + ) + args = parser.parse_args(argv) + root = args.root.resolve() if args.root else repo_root() + source_path = source_for_check(root, args.source) + if source_path is None: + return 2 + data = load_source(source_path) + identity_errors = check_instance_identity(root, data, source_path) + if identity_errors: + print("required-checks gate FAILED:", file=sys.stderr) + for message in identity_errors: + print(f" - {message}", file=sys.stderr) + return 1 + pairs = declared_checks(data) + if args.workflow is not None: + workflow_path = args.workflow + if not workflow_path.is_file(): + print(f"workflow file not found: {workflow_path}", file=sys.stderr) + return 2 + required = [name for name, _workflow in pairs] + published = workflow_job_names(workflow_path, root) + errors = compare(required, published) + published_names = published + else: + by_workflow: dict[str, list[str]] = defaultdict(list) + for name, workflow in pairs: + by_workflow[workflow].append(name) + errors = [] + published_names: list[str] = [] + for workflow, required in by_workflow.items(): + workflow_path = root / workflow + if not workflow_path.is_file(): + print(f"workflow file not found: {workflow_path}", file=sys.stderr) + return 2 + published = workflow_job_names(workflow_path, root) + published_names.extend(published) + errors.extend(compare(required, published)) + if errors: + print("required-checks gate FAILED:", file=sys.stderr) + for err in errors: + print(f" - {err}", file=sys.stderr) + return 1 + required_names = [name for name, _workflow in pairs] + if __name__ == "__main__": + print_check_success(root, source_path, required_names, published_names) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/decision-record.schema.json b/.governance/decision-record.schema.json new file mode 100644 index 0000000..933dfbe --- /dev/null +++ b/.governance/decision-record.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/decision-record.schema.json", + "title": "Recomputable autonomous decision record", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "decisionId", + "ticket", + "headSha", + "correlationId", + "actor", + "appliedRule", + "inputs", + "verdict", + "verdictAuthority", + "rejected", + "assertions" + ], + "properties": { + "schema": { "const": "new-project.decision-record/v1" }, + "decisionId": { + "type": "string", + "pattern": "^D-[0-9]{3}-[0-9]{4,}$" + }, + "ticket": { + "type": "string", + "pattern": "^ticket-[0-9]{3,}$" + }, + "headSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "correlationId": { + "type": "string", + "minLength": 8, + "maxLength": 200 + }, + "actor": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "appliedRule": { + "type": "string", + "pattern": "^[A-Z]+-[A-Z0-9]+-[0-9]{3}$|^P-CORE-[0-9]{3}$|^C-[A-Z]+-[0-9]{3}$" + }, + "inputs": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": ["string", "number", "boolean", "array", "object", "null"] + } + }, + "verdict": { + "type": "string", + "enum": ["APPROVE", "REQUEST_CHANGES", "BLOCKED", "SKIP"] + }, + "verdictAuthority": { + "type": "string", + "enum": ["DETERMINISTIC", "ADVISORY"] + }, + "rejected": { + "type": "object", + "additionalProperties": false, + "required": ["alternative", "because"], + "properties": { + "alternative": { "type": "string", "minLength": 1 }, + "because": { "type": "string", "minLength": 1 } + } + }, + "advisory": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "llmVerdict": { "type": "string" }, + "model": { "type": "string" } + } + }, + "assertions": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "derivedFrom": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "changeEvaluationSchema": { "const": "t2c.change-evaluation/v1" }, + "evaluationPath": { "type": "string" } + } + } + } +} diff --git a/.governance/decision_record.py b/.governance/decision_record.py new file mode 100755 index 0000000..de47752 --- /dev/null +++ b/.governance/decision_record.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python3 +"""Parse, serialize, replay and append-only-check decision records (ticket-031). + +DSL form and JSON (governance/decision-record.schema.json) are mutually +derivable. Verdicts with authority ADVISORY are never trusted: replay always +recomputes from INPUT + APPLIED_RULE. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + +SCHEMA = "new-project.decision-record/v1" +ACTION_EVIDENCE = { + "read-only": (False, "observation"), + "local-check": (False, "check-report"), + "routine-edit": (False, "existing-intent-and-diff"), + "format": (False, "existing-intent-and-diff"), + "evidence-write": (False, "existing-effect-receipt"), + "checkpoint": (False, "continuity-receipt"), + "scope-change": (True, "scope-decision"), + "authority-change": (True, "authority-decision"), + "publication": (True, "protected-controller-receipt"), + "destructive-change": (True, "authorized-effect-decision"), +} + + +def classify_action(action: str) -> dict[str, Any]: + """Classify evidence needs, never the caller's authority to perform an effect. + + The controller must independently verify the actual operation, intent and + lease. A caller-supplied label cannot downgrade a protected effect. + """ + if not isinstance(action, str) or action not in ACTION_EVIDENCE: + raise ValueError("unknown action; use the closed action vocabulary") + required, evidence = ACTION_EVIDENCE[action] + return { + "schema": "new-project.action-classification/v1", + "action": action, + "decisionRecordRequired": required, + "evidenceKind": evidence, + "reuseMatchingEvidence": True, + "createsWorktree": False, + "grantsAuthority": False, + } + + +DECISION_START = re.compile(r"^DECISION\s+(D-\d{3}-\d{4,})\s*$") +FIELD = re.compile(r"^([A-Z][A-Z0-9_]*)\s+(.+)$") +INPUT_LINE = re.compile(r"^INPUT\s+([A-Za-z0-9_]+)\s*=\s*(.+)$") +VERDICT_LINE = re.compile( + r"^VERDICT\s+(\S+)\s+AUTHORITY\s+(DETERMINISTIC|ADVISORY)\s*$" +) +REJECTED_LINE = re.compile(r"^REJECTED\s+(\S+)\s+BECAUSE\s+(.+)$") +ADVISORY_LINE = re.compile( + r'^ADVISORY\s+llm_verdict\s*=\s*("(?:[^"\\]|\\.)*")\s+' + r'MODEL\s+("(?:[^"\\]|\\.)*")\s*$' +) +ASSERT_LINE = re.compile(r"^ASSERT\s+(.+)$") + + +def parse_value(raw: str) -> Any: + raw = raw.strip() + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw + + +def format_value(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def decision_body(text: str) -> str: + body = text.strip() + if body.startswith("```"): + lines = body.splitlines() + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + body = "\n".join(lines).strip() + return body + + +def apply_named_field(record: dict[str, Any], key: str, value: str) -> bool: + destinations = { + "TICKET": "ticket", + "HEAD_SHA": "headSha", + "CORRELATION_ID": "correlationId", + "ACTOR": "actor", + "APPLIED_RULE": "appliedRule", + } + destination = destinations.get(key) + if destination is None: + return False + record[destination] = value + return True + + +def apply_decision_line(record: dict[str, Any], line: str) -> bool: + match = DECISION_START.match(line) + if match: + record["decisionId"] = match.group(1) + return True + match = INPUT_LINE.match(line) + if match: + record["inputs"][match.group(1)] = parse_value(match.group(2)) + return True + match = VERDICT_LINE.match(line) + if match: + record["verdict"] = match.group(1) + record["verdictAuthority"] = match.group(2) + return True + match = REJECTED_LINE.match(line) + if match: + record["rejected"] = { + "alternative": match.group(1), + "because": match.group(2).strip(), + } + return True + match = ADVISORY_LINE.match(line) + if match: + try: + advisory = { + "llmVerdict": json.loads(match.group(1)), + "model": json.loads(match.group(2)), + } + except json.JSONDecodeError: + return False + record["advisory"] = advisory + return True + match = ASSERT_LINE.match(line) + if match: + record["assertions"].append(match.group(1).strip()) + return True + match = FIELD.match(line) + return bool(match and apply_named_field(record, match.group(1), match.group(2).strip())) + + +def require_decision_fields(record: dict[str, Any]) -> None: + required = [ + "decisionId", + "ticket", + "headSha", + "correlationId", + "actor", + "appliedRule", + "verdict", + "verdictAuthority", + "rejected", + ] + missing = [key for key in required if key not in record] + if missing: + raise ValueError(f"decision record missing fields: {missing}") + if not record["inputs"]: + raise ValueError("decision record has no INPUT lines") + if not record["assertions"]: + raise ValueError("decision record has no ASSERT lines") + + +def parse_dsl_record(text: str) -> dict[str, Any]: + record: dict[str, Any] = { + "schema": SCHEMA, + "inputs": {}, + "assertions": [], + "advisory": None, + "derivedFrom": None, + } + for line in decision_body(text).splitlines(): + line = line.rstrip() + if not line or line.startswith("#"): + continue + if not apply_decision_line(record, line): + raise ValueError(f"unrecognized decision-record line: {line}") + require_decision_fields(record) + return record + + +def to_dsl(record: dict[str, Any]) -> str: + lines = [ + f"DECISION {record['decisionId']}", + f"TICKET {record['ticket']}", + f"HEAD_SHA {record['headSha']}", + f"CORRELATION_ID {record['correlationId']}", + f"ACTOR {record['actor']}", + f"APPLIED_RULE {record['appliedRule']}", + ] + for key in sorted(record["inputs"]): + lines.append(f"INPUT {key} = {format_value(record['inputs'][key])}") + lines.append( + f"VERDICT {record['verdict']} AUTHORITY {record['verdictAuthority']}" + ) + rejected = record["rejected"] + because = " ".join(str(rejected["because"]).splitlines()).strip() + lines.append( + f"REJECTED {rejected['alternative']} BECAUSE {because}" + ) + adv = record.get("advisory") + if adv: + lines.append( + f'ADVISORY llm_verdict = {format_value(adv.get("llmVerdict", ""))} ' + f'MODEL {format_value(adv.get("model", ""))}' + ) + for assertion in record.get("assertions") or []: + assert_text = " ".join(str(assertion).splitlines()).strip() + lines.append(f"ASSERT {assert_text}") + return "\n".join(lines) + "\n" + + +def record_content_hash(record: dict[str, Any]) -> str: + # Hash the canonical DSL without relying on insertion order of free text. + canonical = to_dsl(record) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _required_check_names(value: Any) -> list[str]: + if not isinstance(value, list) or not value or any( + not isinstance(name, str) or not name.strip() or name != name.strip() + for name in value + ) or len(set(value)) != len(value): + raise ValueError("GOV-DECISION-002: required_checks must contain unique nonempty check names") + return value + + +def _checkout_required_checks() -> list[str]: + for candidate in (".governance/required-checks.json", "governance/required-checks.json"): + path = Path(candidate) + if not path.exists(): + continue + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise ValueError("GOV-DECISION-002: local required-checks metadata is unreadable") from error + if not isinstance(document, dict): + raise ValueError("GOV-DECISION-002: local required-checks metadata must be an object") + value = document.get("requiredCheckNames") + if value is None: + entries = document.get("requiredChecks") + if isinstance(entries, list): + value = [item.get("name") if isinstance(item, dict) else None for item in entries] + return _required_check_names(value) + raise ValueError("GOV-DECISION-002: local required-checks metadata is missing") + + +def replay_verdict(record: dict[str, Any]) -> str: + """Recompute verdict from INPUT + APPLIED_RULE without reading ADVISORY.""" + if record.get("verdictAuthority") == "ADVISORY": + raise ValueError("GOV-DECISION-003: verdict authority must not be ADVISORY") + rule = record["appliedRule"] + inputs = record["inputs"] + + # P-CORE-015 / check-gate family: required checks must all PASS. + if isinstance(rule, str) and rule in {"P-CORE-015", "C-CI-001", "C-DECISION-GATE"}: + required = inputs.get("required_checks") + observed = inputs.get("observed_checks") + if not isinstance(required, list) or not isinstance(observed, list): + raise ValueError( + "GOV-DECISION-002: check-gate rules require " + "required_checks and observed_checks arrays" + ) + required = _required_check_names(required) + status: dict[str, str] = {} + for item in observed: + if not isinstance(item, str) or "=" not in item: + raise ValueError( + f"GOV-DECISION-002: observed_checks entry not name=STATUS: {item!r}" + ) + name, st = item.split("=", 1) + st = st.upper() + if name in status and status[name] != st: + raise ValueError("GOV-DECISION-002: contradictory observed check statuses") + status[name] = st + for name in required: + st = status.get(str(name)) + if st != "PASS" and st != "SUCCESS": + return "REQUEST_CHANGES" + if "evaluation_verdict" in inputs and str(inputs["evaluation_verdict"]).lower() not in {"allow", "approve"}: + return "REQUEST_CHANGES" + unsafe = inputs.get("unsafe_change_reasons") or [] + if unsafe: + return "REQUEST_CHANGES" + return "APPROVE" + + raise ValueError( + f"GOV-DECISION-002: no deterministic replay for APPLIED_RULE {rule}" + ) + + +def validate_record(record: dict[str, Any]) -> list[str]: + errors: list[str] = [] + if record.get("schema") != SCHEMA: + errors.append("GOV-DECISION-002: unsupported schema") + if record.get("verdictAuthority") != "DETERMINISTIC": + errors.append("GOV-DECISION-003: VERDICT_AUTHORITY must be DETERMINISTIC") + for assertion in record.get("assertions") or []: + if ( + "VERDICT_AUTHORITY" in assertion + and "ADVISORY" in assertion + and record.get("verdictAuthority") == "ADVISORY" + ): + errors.append("GOV-DECISION-003: assertion forbids ADVISORY authority") + try: + recomputed = replay_verdict(record) + except ValueError as exc: + errors.append(str(exc)) + return errors + if recomputed != record.get("verdict"): + errors.append( + "GOV-DECISION-004: replayed verdict " + f"{recomputed!r} != recorded {record.get('verdict')!r}" + ) + return errors + + +def split_decision_blocks(markdown: str) -> list[str]: + """Extract bare and fenced DSL decisions once, in document order.""" + def decisions(text: str) -> list[str]: + parts = re.split(r"(?=^DECISION\s+D-)", text.strip(), flags=re.MULTILINE) + return [part.strip() for part in parts if part.strip().startswith("DECISION ")] + + blocks: list[str] = [] + cursor = 0 + fences = re.finditer( + r"^[ \t]{0,3}```([^\n]*)\n(.*?)^[ \t]{0,3}```[ \t]*(?:\n|$)", + markdown, flags=re.MULTILINE | re.DOTALL, + ) + for fence in fences: + blocks.extend(decisions(markdown[cursor:fence.start()])) + if fence.group(1).strip() == "dsl": + blocks.extend(decisions(fence.group(2))) + cursor = fence.end() + blocks.extend(decisions(markdown[cursor:])) + return blocks + + +def check_append_only(previous_markdown: str, current_markdown: str) -> list[str]: + """Fail if any earlier decision record was modified or removed.""" + prev_blocks = split_decision_blocks(previous_markdown) + curr_blocks = split_decision_blocks(current_markdown) + errors: list[str] = [] + if len(curr_blocks) < len(prev_blocks): + errors.append( + "GOV-DECISION-001: decision log shrank " + f"({len(prev_blocks)} -> {len(curr_blocks)}); append-only violated" + ) + return errors + for idx, prev in enumerate(prev_blocks): + prev_rec = parse_dsl_record(prev) + curr_rec = parse_dsl_record(curr_blocks[idx]) + if record_content_hash(prev_rec) != record_content_hash(curr_rec): + errors.append( + "GOV-DECISION-001: earlier decision " + f"{prev_rec.get('decisionId')} was modified (append-only)" + ) + return errors + + +def from_change_evaluation(evaluation: dict[str, Any], **meta: Any) -> dict[str, Any]: + """Derive a decision record from t2c.change-evaluation/v1 (no dual truth).""" + if evaluation.get("schemaVersion") != "t2c.change-evaluation/v1": + raise ValueError("expected t2c.change-evaluation/v1") + subject = evaluation["subject"] + contract = evaluation["contract"] + verdict_map = { + "allow": "APPROVE", + "deny": "REQUEST_CHANGES", + "approve": "APPROVE", + "request_changes": "REQUEST_CHANGES", + } + raw = str(evaluation.get("verdict", "")).lower() + verdict = verdict_map.get(raw, "BLOCKED") + gates = evaluation.get("gates") or {} + observed = [] + if isinstance(gates, dict): + for name, state in gates.items(): + observed.append(f"{name}={str(state).upper()}") + required = meta.get("required_checks") + if required is None: + required = _checkout_required_checks() + required = _required_check_names(required) + record = { + "schema": SCHEMA, + "decisionId": meta["decisionId"], + "ticket": contract["ticket"], + "headSha": subject["headSha"], + "correlationId": meta["correlationId"], + "actor": meta.get("actor", "agent:validator"), + "appliedRule": meta.get("appliedRule", "P-CORE-015"), + "inputs": { + "required_checks": required, + "observed_checks": observed or meta.get("observed_checks", []), + "evaluation_verdict": evaluation.get("verdict"), + }, + "verdict": verdict if verdict != "BLOCKED" else "REQUEST_CHANGES", + "verdictAuthority": "DETERMINISTIC", + "rejected": { + "alternative": "APPROVE" + if verdict != "APPROVE" + else "REQUEST_CHANGES", + "because": meta.get( + "because", + "DERIVED_FROM_CHANGE_EVALUATION", + ), + }, + "advisory": None, + "assertions": ['VERDICT_AUTHORITY != "ADVISORY"'], + "derivedFrom": { + "changeEvaluationSchema": "t2c.change-evaluation/v1", + "evaluationPath": meta.get("evaluationPath", "change-evaluation.json"), + }, + } + return record + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="cmd", required=True) + + p_classify = sub.add_parser( + "classify-action", help="read-only evidence classification; grants no authority" + ) + p_classify.add_argument("--action", required=True, choices=sorted(ACTION_EVIDENCE)) + + p_val = sub.add_parser("validate-dsl", help="validate one DSL decision record") + p_val.add_argument("path", type=Path) + + p_rep = sub.add_parser("replay", help="print recomputed verdict") + p_rep.add_argument("path", type=Path) + + p_app = sub.add_parser( + "check-append-only", + help="compare previous and current decision log markdown", + ) + p_app.add_argument("previous", type=Path) + p_app.add_argument("current", type=Path) + + args = parser.parse_args(argv) + if args.cmd == "classify-action": + print(json.dumps(classify_action(args.action), sort_keys=True)) + return 0 + if args.cmd == "validate-dsl": + record = parse_dsl_record(args.path.read_text(encoding="utf-8")) + errors = validate_record(record) + if errors: + print("FAIL", file=sys.stderr) + for e in errors: + print(e, file=sys.stderr) + return 1 + print("VALID_RECORD", record["decisionId"], "recorded=" + record["verdict"], + "trustedApproval=false") + return 0 + if args.cmd == "replay": + record = parse_dsl_record(args.path.read_text(encoding="utf-8")) + print(replay_verdict(record)) + return 0 + if args.cmd == "check-append-only": + errors = check_append_only( + args.previous.read_text(encoding="utf-8"), + args.current.read_text(encoding="utf-8"), + ) + if errors: + for e in errors: + print(e, file=sys.stderr) + return 1 + print("append-only OK") + return 0 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/diagnostics.json b/.governance/diagnostics.json new file mode 100644 index 0000000..84d09d6 --- /dev/null +++ b/.governance/diagnostics.json @@ -0,0 +1,550 @@ +{ + "schema": "new-project.diagnostics/v2", + "codes": { + "GOV-WORK-START-001": { + "message": "New work admission requires reconciliation of existing repository work or incomplete observations.", + "remediation": "Run the managed work-start check; reuse the matching ticket, assist read-only, arrange a fenced handoff or serialize. Preserve unknown work; do not force allocation.", + "documentation": "error/GOV-WORK-START.md" + }, + "GOV-AGENT-HOST-001": { + "message": "Commit is not bound to an IN_PROGRESS ticket-NNN branch.", + "remediation": "Inspect existing branches and worktrees first; reuse the allocated ticket checkout. Allocate through ./project/new-ticket.sh only after work-start admission; never rename or discard unknown work to satisfy the hook.", + "documentation": "error/GOV-AGENT-HOST.md" + }, + "GOV-AGENT-HOST-002": { + "message": "Staged snapshot has no ticket README for the branch ticket.", + "remediation": "Stage the allocated project/ticket-NNN/README.md; never invent a ticket number.", + "documentation": "error/GOV-AGENT-HOST.md" + }, + "GOV-AGENT-HOST-003": { + "message": "A repository commit attempts to write terminal ticket closure state.", + "remediation": "Leave the reviewed repository payload unchanged and let the protected delivery controller emit the external merge or no-change receipt.", + "documentation": "error/GOV-AGENT-HOST.md" + }, + "GOV-AGENT-HOST-004": { + "message": "A declared LLM host instruction file is missing or the host contract is unreadable.", + "remediation": "Bootstrap with ./scripts/install-agent-hosts.sh --source --target , or adopt the current standard package.", + "documentation": "error/GOV-AGENT-HOST.md" + }, + "GOV-AGENT-HOST-005": { + "message": "The fail-closed pre-commit hook is missing or not executable.", + "remediation": "Restore .githooks/pre-commit through adoption and make it executable with chmod +x.", + "documentation": "error/GOV-AGENT-HOST.md" + }, + "GOV-AGENT-HOST-006": { + "message": "core.hooksPath does not point at the managed hook directory.", + "remediation": "Activate the managed hook: git config core.hooksPath .githooks in this clone.", + "documentation": "error/GOV-AGENT-HOST.md" + }, + "GOV-AGENT-HOST-007": { + "message": "A staged commit contains only ticket tracking carriers.", + "remediation": "Add the material deliverable, or emit an external no-change receipt and create no repository commit.", + "documentation": "error/GOV-AGENT-HOST.md" + }, + "GOV-APPROVAL-001": { + "message": "Implementation lacks approval from a trusted external source.", + "remediation": "Observe the exact PR/head and existing review request; invoke the configured protected controller within existing publication authority, or route to the trusted reviewer. Read back its receipt before retry; green checks alone are not approval.", + "documentation": "error/GOV-APPROVAL.md" + }, + "GOV-APPROVAL-002": { + "message": "Approval refers to a different ticket.", + "remediation": "Reconcile the current ticket and PR binding, then request protected review of that exact subject. Never edit old approval evidence to name another ticket.", + "documentation": "error/GOV-APPROVAL.md" + }, + "GOV-APPROVAL-003": { + "message": "Approval evidence is missing, repository-controlled or structurally invalid.", + "remediation": "Create v1 approval evidence outside the PR checkout through a protected verifier.", + "documentation": "error/GOV-APPROVAL.md" + }, + "GOV-APPROVAL-004": { + "message": "Approval evidence is bound to another repository, pull request or commit.", + "remediation": "Regenerate protected evidence for the exact repository, PR, HEAD and ticket tuple.", + "documentation": "error/GOV-APPROVAL.md" + }, + "GOV-APPROVAL-005": { + "message": "Approval actor or verification method is not trusted for the claimed source.", + "remediation": "Use the type-specific protected allowlist or verify a signed attestation with a trusted issuer.", + "documentation": "error/GOV-APPROVAL.md" + }, + "GOV-ARCHITECTURE-001": { + "message": "Architecture ownership, UI/data impact or component mapping is unresolved.", + "remediation": "Classify actual data impact and resolve component or integration ownership under existing authority; follow the runbook before escalating.", + "documentation": "error/GOV-ARCHITECTURE-001.md" + }, + "GOV-BASE-001": { + "message": "The target branch or base SHA differs from the approved delivery contract.", + "remediation": "Rebase or rebuild from the accepted base, then update intent only through an authorized scope review.", + "documentation": null + }, + "GOV-BASE-002": { + "message": "Target-branch advancement overlaps a component declared by the active ticket.", + "remediation": "Refresh the branch, rerun validation and obtain fresh scope approval; do not repin for unrelated target changes.", + "documentation": null + }, + "GOV-BOOT-001": { + "message": "A required target-repository file is missing.", + "remediation": "Create the target-owned prerequisite or adopt the managed file through the pinned standard package as applicable.", + "documentation": null + }, + "GOV-BRANCH-LIFECYCLE-001": { + "message": "GitHub automatic head-branch deletion after merge is disabled.", + "remediation": "Set delete_branch_on_merge=true in repository settings.", + "documentation": "error/GOV-WORKSPACE-LIFECYCLE.md" + }, + "GOV-BRANCH-LIFECYCLE-002": { + "message": "Remote branches exist without ownership by an open pull request.", + "remediation": "Observe the exact branch head and open/closed PR history; preserve unmerged work and reconcile its intent before choosing continued delivery or an explicitly authorized discard. Do not create an empty PR or delete a branch merely to satisfy this check.", + "documentation": "error/GOV-WORKSPACE-LIFECYCLE.md" + }, + "GOV-BRANCH-LIFECYCLE-003": { + "message": "The branch lifecycle snapshot is missing, malformed or inconsistent.", + "remediation": "Re-acquire the snapshot from the protected GitHub workflow and reobserve inconsistent refs; preserve every branch while the observation is unresolved.", + "documentation": "error/GOV-WORKSPACE-LIFECYCLE.md" + }, + "GOV-BUDGET-001": { + "message": "The actual implementation diff exceeds its approved budget.", + "remediation": "Reduce the slice or obtain fresh authorization for a larger bounded intent.", + "documentation": null + }, + "GOV-IDLE-TAKEOVER-001": { + "message": "Idle takeover was refused: the ticket is active, frozen, integrated, noncanonical, disabled by policy or its lease changed concurrently.", + "remediation": "Read the reported state on .subactor/cache/autonomy-board.md; continue only an IDLE_TAKEOVER_ELIGIBLE ticket, otherwise assist, reconcile or request an accepted handoff.", + "documentation": "error/GOV-IDLE-TAKEOVER.md" + }, + "GOV-IDLE-TAKEOVER-002": { + "message": "Idle takeover could not observe the repository or write its snapshot, receipt, lease or board.", + "remediation": "Preserve the worktree, fix the reported read or write failure and re-run observe before any takeover.", + "documentation": "error/GOV-IDLE-TAKEOVER.md" + }, + "GOV-CHANGE-LEASE-001": { + "message": "A change lease, transition request or receipt is malformed or not closed.", + "remediation": "Rebuild the document from the managed change-lease schema without undeclared fields.", + "documentation": "error/GOV-CHANGE-LEASE.md" + }, + "GOV-CHANGE-LEASE-002": { + "message": "Change authority is stale or its receipt trace is not monotonic.", + "remediation": "Have the controller verify the calling writer/session and supply current revision, fencing token and phase. A different writer requires the accepted handoff described in error/GOV-WORK-START.md; never copy the holder identity.", + "documentation": "error/GOV-CHANGE-LEASE.md" + }, + "GOV-CHANGE-LEASE-003": { + "message": "A change-lease transition violates the state machine or publication freeze.", + "remediation": "Keep the frozen HEAD immutable and request only a transition allowed from the current phase.", + "documentation": "error/GOV-CHANGE-LEASE.md" + }, + "GOV-CHANGE-LEASE-004": { + "message": "Lease supersession lacks accepted terminal replacement evidence.", + "remediation": "Attach the exact accepted terminal receipt for the replacement lease before superseding.", + "documentation": "error/GOV-CHANGE-LEASE.md" + }, + "GOV-CLASS-000": { + "message": "The work-classification contract is unavailable.", + "remediation": "Restore the managed work-classification DSL from the pinned package before allocating a ticket.", + "documentation": null + }, + "GOV-CLASS-001": { + "message": "A requested work classification value is not declared.", + "remediation": "Choose kind, priority and origin values declared by the managed classification contract.", + "documentation": null + }, + "GOV-CONFLICT-001": { + "message": "Tickets declared as conflicting are active at the same time.", + "remediation": "Move one ticket to BACKLOG, PLAN or BLOCKED until the conflicting work reaches a terminal state.", + "documentation": null + }, + "GOV-CONTINUITY-001": { + "message": "A work-continuity checkpoint is malformed, unsafe or cannot preserve a dirty workspace.", + "remediation": "Rebuild the bounded checkpoint from observable state; commit authorized work or provide a content-addressed, secret-scanned external snapshot.", + "documentation": "error/GOV-WORK-CONTINUITY.md" + }, + "GOV-CONTINUITY-002": { + "message": "The work-continuity receipt chain is stale, rebound or non-monotonic.", + "remediation": "Recover the complete append-only chain from the protected receipt store and extend only its latest valid checkpoint.", + "documentation": "error/GOV-WORK-CONTINUITY.md" + }, + "GOV-CONTINUITY-003": { + "message": "A continuity checkpoint diverges from current repository observation.", + "remediation": "Preserve both states and route to reconciliation or BLOCKED; do not reset, overwrite, restore or reuse a stale fencing token.", + "documentation": "error/GOV-WORK-CONTINUITY.md" + }, + "GOV-DECISION-001": { + "message": "A repository-changing autonomous decision lacks an append-only record or rewrites earlier evidence.", + "remediation": "Append a new recomputable decision record; never edit an earlier record.", + "documentation": null + }, + "GOV-DECISION-002": { + "message": "A decision record is not recomputable.", + "remediation": "Store deterministic inputs verbatim and name a replayable applied rule.", + "documentation": null + }, + "GOV-DECISION-003": { + "message": "A decision record treats advisory LLM output as verdict authority.", + "remediation": "Keep LLM output ADVISORY and derive the verdict from deterministic rules and evidence.", + "documentation": null + }, + "GOV-DECISION-004": { + "message": "Replaying decision inputs diverges from the recorded verdict.", + "remediation": "Correct the new decision record or underlying inputs; do not rewrite historical evidence.", + "documentation": null + }, + "GOV-DELIVERY-001": { + "message": "The implementation slice lacks or exceeds its approved delivery contract.", + "remediation": "Declare a valid bounded delivery block or split the work into a smaller authorized slice.", + "documentation": null + }, + "GOV-DELIVERY-002": { + "message": "The implementation slice reached its pre-stop checkpoint.", + "remediation": "Stop, record evidence and replan the remaining bounded work before continuing.", + "documentation": null + }, + "GOV-DEPENDENCY-001": { + "message": "The ticket dependency graph contains a cycle or self-reference.", + "remediation": "Rewrite dependsOn as an acyclic graph with no ticket depending on itself.", + "documentation": null + }, + "GOV-DEPENDENCY-002": { + "message": "An active ticket depends on a missing or unfinished ticket.", + "remediation": "Complete the dependency or move the dependent ticket out of IN_PROGRESS.", + "documentation": null + }, + "GOV-DIAGNOSTIC-001": { + "message": "The stable diagnostic catalog is malformed or differs from emitted runtime codes.", + "remediation": "Register every emitted code exactly once with a non-empty canonical message and remediation, and remove stale entries.", + "documentation": null + }, + "GOV-DIAGNOSTIC-002": { + "message": "A linked diagnostic runbook is missing, unsafe or structurally incomplete.", + "remediation": "Restore the linked error/*.md page with all required fail-closed sections and a relative path.", + "documentation": null + }, + "GOV-DIFF-001": { + "message": "The changed-path set or commit history could not be determined safely.", + "remediation": "Restore valid Git metadata and provide an explicit base and head before evaluating scope.", + "documentation": null + }, + "GOV-DOCKER-001": { + "message": "The required Docker runtime declaration is incomplete.", + "remediation": "Declare the Docker marker and explicit governed Dockerfile/Compose paths, or truthfully disable the stack.", + "documentation": null + }, + "GOV-DOCKER-002": { + "message": "A Dockerfile or Compose image reference is not pinned to an immutable SHA-256 digest.", + "remediation": "Replace every external image tag with registry/image@sha256 followed by 64 lowercase hex characters.", + "documentation": null + }, + "GOV-ENV-001": { + "message": "Governance environment resolution failed or exposed an undeclared value.", + "remediation": "Use governance_env.py with declared variables, relative ENV_FILE paths and redacted required secrets.", + "documentation": null + }, + "GOV-INTEGRATION-001": { + "message": "A shared contract path lacks valid routing through an integration ticket.", + "remediation": "Route the shared path through the manifest-declared integration workstream without transferring path ownership.", + "documentation": null + }, + "GOV-HOST-MERGE-001": { + "message": "Host instructions carry the superseded autonomous-merge block that allows agents to merge on green or bypass review.", + "remediation": "Preview and apply scripts/host_publication_policy.py from the standard in an owned ticket; only the independent Validator may approve and merge.", + "documentation": "error/GOV-HOST-MERGE.md" + }, + "GOV-INTENT-001": { + "message": "Implementation changed before the ticket entered an implementation state.", + "remediation": "Record authorization and move the active ticket to EDIT, VALIDATION or PUBLICATION before changing implementation.", + "documentation": "error/GOV-INTENT.md" + }, + "GOV-INTENT-002": { + "message": "Ticket intent is missing or malformed.", + "remediation": "Create a schema-valid intent.json with bounded allowedPaths and required delivery evidence.", + "documentation": "error/GOV-INTENT.md" + }, + "GOV-INTENT-003": { + "message": "Ticket intent is absent from the first material implementation commit.", + "remediation": "Include the validated intent atomically with the first material change; do not manufacture a separate plan-only commit.", + "documentation": "error/GOV-INTENT.md" + }, + "GOV-MANIFEST-001": { + "message": "Manifest or managed governance contract is missing, unreadable or structurally invalid.", + "remediation": "Restore the complete pinned governance package and validate its JSON contracts.", + "documentation": null + }, + "GOV-MATERIAL-001": { + "message": "The changeset contains only ticket tracking carriers and no material deliverable.", + "remediation": "Add a material source, test, configuration, standard or requested documentation change; if there is no delta, emit an external no-change receipt without a commit or PR.", + "documentation": null + }, + "GOV-OWNER-001": { + "message": "An untrusted actor changed a human-owned participant file.", + "remediation": "Revert the agent-authored human file change and obtain input from the human owner or trusted intake boundary.", + "documentation": null + }, + "GOV-PACKAGING-001": { + "message": "Package metadata declares no wellmanifest governance block.", + "remediation": "Declare the adopted standard version, revision and gate in pyproject.toml or package.json.", + "documentation": "error/GOV-PACKAGING.md" + }, + "GOV-PACKAGING-002": { + "message": "Package governance declaration disagrees with the adoption lock.", + "remediation": "Regenerate the package declaration from .governance/manifest.lock.json.", + "documentation": "error/GOV-PACKAGING.md" + }, + "GOV-PACKAGING-003": { + "message": "Packaging lifecycle does not run the governance gate.", + "remediation": "Bind the gate to scripts.prepare or the pytest addopts so the tooling runs it unprompted.", + "documentation": "error/GOV-PACKAGING.md" + }, + "GOV-PATH-001": { + "message": "A committed governance artifact contains a machine-local absolute path.", + "remediation": "Replace the local path with a repository-relative reference and sanitize committed logs.", + "documentation": null + }, + "GOV-PHYS-001": { + "message": "A changed path carries a declared physical contract but the intent declares no physical change for it.", + "remediation": "Declare every changed physical property with before, after and hardware acceptance checks, or property none with a rationale.", + "documentation": "error/GOV-PHYS.md" + }, + "GOV-PHYS-002": { + "message": "The adopter's physical contract declaration is missing required fields or unreadable.", + "remediation": "Repair physical-contracts.json; the gate fails closed while it is invalid.", + "documentation": "error/GOV-PHYS.md" + }, + "GOV-POLICY-DSL-001": { + "message": "CONTRIBUTING.md or its pinned Policy DSL runtime is invalid.", + "remediation": "Restore the managed Policy DSL files or correct the selected dsl fences, then rerun the governance gate.", + "documentation": null + }, + "GOV-PULL-REQUEST-STATE-001": { + "message": "The protected workflow cannot bind a live pull-request state to its event.", + "remediation": "Re-acquire the pull request through the GitHub API and retry the workflow; do not skip open-PR ticket, scope or approval enforcement.", + "documentation": null + }, + "GOV-PUSH-001": { + "message": "A push would update a declared target branch directly.", + "remediation": "Push the ticket branch and open a pull request; the validator merges it into the target branch.", + "documentation": "error/GOV-PUSH.md" + }, + "GOV-REMEDIATION-001": { + "message": "A diagnostic remediation intent is malformed, unresolved or semantically unsafe.", + "remediation": "Correct the target-owned remediation-intent DSL and pass deterministic schema and semantic validation before LLM planning.", + "documentation": "error/GOV-REMEDIATION-INTENT.md" + }, + "GOV-REMEDIATION-002": { + "message": "A todo2code plan conflicts with accepted remediation scope, criteria, priority or user-state safety.", + "remediation": "Reject or regenerate the conflicting plan; obtain a fresh bounded intent before any material scope or authority expansion.", + "documentation": "error/GOV-REMEDIATION-INTENT.md" + }, + "GOV-REMEDIATION-003": { + "message": "The todo2code advisory overlay is stale for the current remediation intent.", + "remediation": "Discard the overlay and rerun deterministic todo2code analysis against the current authority-bearing intent digest.", + "documentation": "error/GOV-REMEDIATION-INTENT.md" + }, + "GOV-REMEDIATION-004": { + "message": "A declared todo2code remediation projection is missing, stale or outside the selected repository root.", + "remediation": "Render both declared projections atomically from the accepted intent, verify their exact bytes, then rerun todo2code extraction.", + "documentation": "error/GOV-REMEDIATION-INTENT.md" + }, + "GOV-SCOPE-001": { + "message": "A changed implementation path is outside approved intent scope.", + "remediation": "Remove the unrelated change or obtain a fresh bounded intent before editing that path.", + "documentation": null + }, + "GOV-SECRET-001": { + "message": "A changed file contains a probable secret assignment.", + "remediation": "Stop publication, remove and rotate the secret through a trusted boundary, then rescan the exact diff.", + "documentation": null + }, + "GOV-STACK-001": { + "message": "The declared technology stack lacks its required project marker.", + "remediation": "Add a truthful root marker or remove the incorrect stack declaration; do not create a synthetic marker only to pass the gate.", + "documentation": null + }, + "GOV-STATUS-001": { + "message": "A ticket status is missing or not declared by the governance manifest.", + "remediation": "Use one declared active, non-active or closed status and a compatible workflow state.", + "documentation": null + }, + "GOV-STANDARD-UPDATE-001": { + "message": "The pinned Wellmanifest standard cannot be verified or safely prepared during pre-commit.", + "remediation": "Install a compatible Goal release and use one active standard-adoption ticket to prepare, review and restage the immutable update.", + "documentation": "error/GOV-STANDARD-UPDATE.md" + }, + "GOV-STANDARD-PACK-001": { + "message": "The repository does not satisfy the standard-pack baseline selected by its governance profile.", + "remediation": "Adopt every pack required by the selected profile at or above its minimum level, then rerun standard_pack_check.py --strict.", + "documentation": null + }, + "GOV-SYNC-001": { + "message": "A managed governance file does not match its pinned SHA-256 digest.", + "remediation": "Adopt or upgrade the complete immutable package through Goal; do not patch managed payload files manually.", + "documentation": null + }, + "GOV-TICKET-001": { + "message": "Implementation changed without exactly one active ticket.", + "remediation": "Keep the owning ticket IN_PROGRESS through implementation publication; after merge, let the protected controller emit the external terminal receipt without a repository closure commit.", + "documentation": "error/GOV-TICKET-001.md" + }, + "GOV-TICKET-EXTERNAL-001": { + "message": "The selected external ticket backend has no valid active local adapter.", + "remediation": "Refresh the repository-local adapter from the external ticket system and keep its bounded scope, state and source evidence consistent.", + "documentation": null + }, + "GOV-TICKET-ACTIVITY-001": { + "message": "Ticket activity cannot be resolved safely from the managed policy and clone-external terminal receipt registry.", + "remediation": "Keep the status projection active, then reconcile or recover the clone-external registry from protected evidence.", + "documentation": "error/GOV-TICKET-ACTIVITY.md" + }, + "GOV-TICKET-002": { + "message": "More than one active ticket exists where the manifest permits only one.", + "remediation": "Continue the matching ticket and move unrelated waiting work to BACKLOG, PLAN or BLOCKED.", + "documentation": null + }, + "GOV-TICKET-003": { + "message": "An active ticket is malformed or missing a required governance file.", + "remediation": "Restore its README, preprompt, changelog, intent and typed agent participant files before implementation.", + "documentation": null + }, + "GOV-TICKET-004": { + "message": "Executable source, test or research content is stored in a ticket directory.", + "remediation": "Move executable material to its normal source, scripts or tests directory and keep only evidence in the ticket.", + "documentation": null + }, + "GOV-TICKET-005": { + "message": "Implementation paths do not resolve to exactly one active ticket.", + "remediation": "Split unrelated paths or correct non-overlapping allowedPaths and workstream ownership.", + "documentation": null + }, + "GOV-TICKET-ALLOCATION-001": { + "message": "A ticket claim is outside a valid clone-wide high-water reservation.", + "remediation": "Preserve the worktree, classify ownership and allocate a fresh ID only through project/new-ticket.sh.", + "documentation": "error/GOV-TICKET-ALLOCATION.md" + }, + "GOV-TICKET-ALLOCATION-002": { + "message": "Linked worktrees assign different intents to the same ticket ID.", + "remediation": "Stop both writers, preserve both heads and reallocate the later intent through project/new-ticket.sh before rebuilding its branch.", + "documentation": "error/GOV-TICKET-ALLOCATION.md" + }, + "GOV-TICKET-ALLOCATION-003": { + "message": "A distributed writer lacks a valid receipt from the registered atomic ticket allocator.", + "remediation": "Submit the emitted exact request to the configured process URI and retry with its fresh fenced receipt; never choose a local number.", + "documentation": "error/GOV-TICKET-ALLOCATION.md" + }, + "GOV-TICKET-ALLOCATION-004": { + "message": "A registered allocation resolves to a ticket identity already visible in repository state.", + "remediation": "Continue the existing claim or request a fresh fenced allocation after reconciliation; do not recreate, rename or overwrite it.", + "documentation": "error/GOV-TICKET-ALLOCATION.md" + }, + "GOV-TICKET-LOCK-001": { + "message": "Another ticket allocation holds the clone-wide lock.", + "remediation": "Wait for the allocator; remove a stale lock only after proving no allocation process is active.", + "documentation": "error/GOV-TICKET-ALLOCATION.md" + }, + "GOV-TICKET-LOCK-002": { + "message": "The clone-wide ticket high-water state is invalid.", + "remediation": "Preserve ticket worktrees and repair the shared numeric reservation before assigning another ID.", + "documentation": "error/GOV-TICKET-ALLOCATION.md" + }, + "GOV-TICKET-LOCK-003": { + "message": "The ticket directory selected by the allocator already exists.", + "remediation": "Stop and classify the existing claim; never overwrite or rename it automatically.", + "documentation": "error/GOV-TICKET-ALLOCATION.md" + }, + "GOV-TICKET-LOCK-004": { + "message": "Remote ticket refs could not be refreshed before allocation.", + "remediation": "Restore origin connectivity and retry; do not allocate from stale refs.", + "documentation": "error/GOV-TICKET-ALLOCATION.md" + }, + "GOV-WORKSPACE-LIFECYCLE-001": { + "message": "A terminal workspace still contains a linked worktree.", + "remediation": "Verify dirty state and HEAD reachability, then remove only the exact disposable worktree through Git.", + "documentation": "error/GOV-WORKSPACE-LIFECYCLE.md" + }, + "GOV-WORKSPACE-LIFECYCLE-002": { + "message": "A terminal workspace still contains a duplicate clone.", + "remediation": "Verify it has no unique data, then move the exact duplicate checkout to recoverable trash.", + "documentation": "error/GOV-WORKSPACE-LIFECYCLE.md" + }, + "GOV-WORKSPACE-LIFECYCLE-003": { + "message": "The local workspace audit could not be completed safely.", + "remediation": "Repair repository metadata or narrow the explicit workspace root before cleanup.", + "documentation": "error/GOV-WORKSPACE-LIFECYCLE.md" + }, + "GOV-WORKSPACE-LIFECYCLE-004": { + "message": "A terminal workspace still contains a non-default local branch.", + "remediation": "Classify its HEAD, preserve unique history, release any worktree and delete only the exact disposable local ref.", + "documentation": "error/GOV-WORKSPACE-LIFECYCLE.md" + }, + "GOV-WORKSTREAM-001": { + "message": "An active ticket declares a missing or unknown workstream.", + "remediation": "Choose a workstream declared by the current governance manifest.", + "documentation": null + }, + "GOV-WORKSTREAM-002": { + "message": "A workstream exceeds its active-ticket limit.", + "remediation": "Keep one implementation owner active and release waiting reservations.", + "documentation": null + }, + "GOV-WORKSTREAM-003": { + "message": "A changed path is not owned by the ticket workstream.", + "remediation": "Move the path to the owning workstream or correct the manifest through an authorized integration change.", + "documentation": null + }, + "GOV-WORKSTREAM-004": { + "message": "Active ticket write scopes overlap on a concrete repository path.", + "remediation": "Serialize the work or narrow allowedPaths until each changed path has exactly one owner.", + "documentation": null + }, + "GOV-WORKTREE-OVERLAP-001": { + "message": "Two worktrees of the same repository are changing the same paths.", + "remediation": "Stop one writer, declare conflictsWith, or move the overlapping paths to a single ticket / integration workstream before merge.", + "documentation": "error/GOV-WORKTREE-OVERLAP.md" + }, + "GOV-WORKTREE-OVERLAP-002": { + "message": "IN_PROGRESS tickets in sibling worktrees claim overlapping allowedPaths without conflictsWith.", + "remediation": "Add conflictsWith on both intents, serialize one ticket to BACKLOG/PLAN/BLOCKED, or narrow allowedPaths so they no longer overlap.", + "documentation": "error/GOV-WORKTREE-OVERLAP.md" + }, + "GOV-WORKTREE-OVERLAP-003": { + "message": "The worktree overlap audit could not be completed safely.", + "remediation": "Repair repository metadata or narrow the explicit workspace root before relying on the guard.", + "documentation": "error/GOV-WORKTREE-OVERLAP.md" + }, + "GOV-BRANCH-INTENT-001": { + "message": "Branch intent reconciliation is incomplete, stale or invalid.", + "remediation": "Preserve history, reacquire the complete accepted criterion inventory and independently verified receipts at the exact source/target SHA; retain unknown work for review. Report conformance never authorizes deletion.", + "documentation": null + }, + "GOV-SNAPSHOT-MIGRATION-001": { + "message": "The snapshot migration contract is invalid.", + "remediation": "Preserve the source; reobserve the exact subject and protected grant before a bounded successor action.", + "documentation": "error/GOV-SNAPSHOT-MIGRATION.md" + }, + "GOV-SNAPSHOT-MIGRATION-002": { + "message": "The immutable migration subject or complete Git history is unavailable.", + "remediation": "Preserve the source; reobserve the exact subject and protected grant before a bounded successor action.", + "documentation": "error/GOV-SNAPSHOT-MIGRATION.md" + }, + "GOV-SNAPSHOT-MIGRATION-003": { + "message": "The migration authorization is missing or differs from the protected subject.", + "remediation": "Preserve the source; reobserve the exact subject and protected grant before a bounded successor action.", + "documentation": "error/GOV-SNAPSHOT-MIGRATION.md" + }, + "GOV-SNAPSHOT-MIGRATION-004": { + "message": "The migration does not preserve source ancestry and atomic new intent.", + "remediation": "Preserve the source; reobserve the exact subject and protected grant before a bounded successor action.", + "documentation": "error/GOV-SNAPSHOT-MIGRATION.md" + }, + "GOV-SNAPSHOT-MIGRATION-005": { + "message": "The migration inventory or imported tree differs from its authorized snapshot.", + "remediation": "Preserve the source; reobserve the exact subject and protected grant before a bounded successor action.", + "documentation": "error/GOV-SNAPSHOT-MIGRATION.md" + }, + "GOV-SNAPSHOT-MIGRATION-006": { + "message": "The migration authorization was consumed or its one-use base or ticket changed.", + "remediation": "Preserve the source; reobserve the exact subject and protected grant before a bounded successor action.", + "documentation": "error/GOV-SNAPSHOT-MIGRATION.md" + }, + "GOV-VERSION-001": { + "message": "Declared package version does not match root VERSION specification.", + "remediation": "Synchronize package manifests (pyproject.toml, setup.py, package.json, Cargo.toml) and code declarations with the authoritative VERSION file.", + "documentation": "error/GOV-VERSION.md" + } + } +} diff --git a/.governance/diagnostics.schema.json b/.governance/diagnostics.schema.json new file mode 100644 index 0000000..273cdd3 --- /dev/null +++ b/.governance/diagnostics.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/new-project-diagnostics-v2.json", + "title": "new-project stable diagnostic catalog", + "type": "object", + "additionalProperties": false, + "required": ["schema", "codes"], + "properties": { + "schema": {"const": "new-project.diagnostics/v2"}, + "codes": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^GOV-[A-Z]+(?:-[A-Z]+)*-[0-9]{3}$" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["message", "remediation", "documentation"], + "properties": { + "message": {"type": "string", "minLength": 1}, + "remediation": {"type": "string", "minLength": 1}, + "documentation": { + "oneOf": [ + {"type": "null"}, + {"type": "string", "pattern": "^error/[^/]+\\.md$"} + ] + } + } + } + } + } +} diff --git a/.governance/docs/BRANCH_INTENT_RECONCILIATION.md b/.governance/docs/BRANCH_INTENT_RECONCILIATION.md new file mode 100644 index 0000000..aaa8d88 --- /dev/null +++ b/.governance/docs/BRANCH_INTENT_RECONCILIATION.md @@ -0,0 +1,105 @@ +# Reconcile intent before discarding an unmerged branch + +Before proposing an unmerged branch for discard, reconcile its accepted intent +against the current target branch. An orphaned branch is a lifecycle finding, +not evidence that its work is obsolete. A backup is preservation, not a promise +to implement the work later. Keep the decision about requirements separate from +the decision about a Git ref. + +## Required procedure + +1. Observe the repository, exact source ref/SHA, target ref/SHA, source intent + bytes and SHA-256, PRs, dirty state, leases and active processes. Preserve + unknown work. Obtain and verify a recoverable archive; include dirty work + only through the existing secret-scanned snapshot boundary. +2. Derive the complete criterion inventory from the accepted intent and its + acceptance criteria. Bind that derivation to the source intent digest and + preserve source references. Missing or ambiguous intent requires review; do + not invent a smaller inventory to make a report pass. +3. Check Git ancestry, then patch/content equivalence. Different commit IDs do + not imply different functionality (squash, cherry-pick and reimplementation + change history). Equal patch IDs alone do not prove present-day behavior. + For code changes, bind relevant behavioral tests to the current target SHA. +4. Classify every criterion: `implemented`, `partial`, `superseded`, `missing` + or `unknown`. Cite immutable evidence. For `partial`/`missing`, preserve a + linked follow-up ticket and intent, or an explicit owner discard decision. + `superseded` requires the accepted decision that replaced the requirement. + `unknown` never permits automatic resolution, even with a backup. +5. Present this reconciliation before requesting any new owner decision. Reuse + existing authority only if it explicitly covers the exact operation. A + report does not grant branch deletion, ticket closure or merge authority. +6. The protected controller rechecks authority, the receipt chain, source and + target SHA, archive integrity and exact ref immediately before a mutation. + A moved head invalidates the observation; reacquire it. Retain an external + operation receipt and the linked residual-work disposition. Follow the + existing worktree/branch cleanup and protected publication procedures. + +## Read-only conformance boundary + +The managed checker validates a report against an independently acquired +observation and a content-addressed evidence directory: + +```bash +python3 .governance/branch_intent_reconciliation.py \ + --report /external/report.json \ + --observation /protected/observation.json \ + --evidence-root /protected/evidence +``` + +In this hub the script is `scripts/branch_intent_reconciliation.py`. All three +input artifact kinds use `branch-intent-reconciliation.schema.json`. + +The report binds `repository`, `sourceRef`, `sourceHeadSha`, `targetRef`, +`targetHeadSha`, and `intentSha256`. Each criterion has `id`, `outcome`, +`evidence` references, and nullable `followUp`/`decision` references. The +preservation reference is mandatory. Each reference is `{receiptRef, sha256}`. + +The observation contains those same bindings, the full `criteria` inventory +with `requiredProof` (`content`, `behavior`, `either`), and a `receipts` map from +verified receipt identity to SHA-256. The controller obtains this map outside +the author's checkout after checking issuer, scope, archive restoration and +evidence provenance. It must not simply copy the report or use an author-made +observation as authority. A hash establishes integrity, not authenticity. + +Each evidence file is named `.json`. It contains its schema identity, +receipt identity, bindings, criterion IDs, kind and closed `facts` object: + +| Kind | Facts verified by conformance | Required upstream verification | +| --- | --- | --- | +| `preservation` | Archive ref/hash and `restoreVerified=true` | Actually restore/verify the archive and its source SHA; retain its storage | +| `content-equivalence` | Source/target paths and equal byte digests | Acquire bytes at both pinned commits; prove they address the criterion | +| `test-result` | Suite/result digests and `passed=true` | Execute relevant tests at the target SHA; verify criterion coverage and logs | +| `decision` | Decision ref, actor, supersede/discard disposition | Verify the actor's authority and the accepted decision bindings | +| `follow-up` | Ticket ref and its intent digest | Verify the ticket exists and owns the outstanding criterion | +| `advisory` | Analysis ref | Never sufficient implementation or disposition evidence | + +The checker reads and hashes receipts, rejects receipts outside the observation +allowlist, checks bindings and criterion coverage, and checks the required proof +kind. It does not execute tests, restore archives, contact GitHub, verify actor +identity or prove arbitrary program equivalence. These belong to evidence +producers and the protected controller. Content proof cannot satisfy a criterion +whose independently selected `requiredProof` is `behavior`. + +Exit 0 means `ready-for-owner-review`, exit 1 means unresolved `needs-review`, +and exit 2 means invalid/incomplete evidence (`GOV-BRANCH-INTENT-001`). Every +result has `authority=none` and `deletionAuthorized=false`. `unknown` remains +unresolved regardless of LLM confidence or whether other criteria pass. + +## Tool composition and prevention + +Intent/Contract DSL can formalize criteria and exclusions. data2dsl can compare +bounded facts while retaining provenance. todo2code can link intent, Git, AST +and tests and identify gaps. Their outputs remain evidence/advice; `MATCH`, +`ALIGNED` or an LLM verdict is not discard authority. + +Preserve the link `criterion -> implementation/test -> replacement/follow-up -> +terminal receipt`. Run reconciliation when work is superseded or a PR closes +without merge, rather than waiting for an orphan-branch publication failure. +Escalate ambiguous criteria with the evidence already collected. Do not create +carrier-only closure PRs, hide missing work behind a backup, or silently mark +unimplemented requirements as completed. + +The package ships the checker, schema and this procedure. Adoption remains an +explicit pinned package upgrade. Product cleanup controllers must call this +boundary with independently verified observations before mutations; shipping +the standard does not automatically install enforcement in every product. diff --git a/.governance/docs/LOCAL_CI_PUBLICATION.md b/.governance/docs/LOCAL_CI_PUBLICATION.md new file mode 100644 index 0000000..e46aee6 --- /dev/null +++ b/.governance/docs/LOCAL_CI_PUBLICATION.md @@ -0,0 +1,31 @@ +# Local CI publication policy — adopted reference + +Canonical policy: [Wellmanifest/new-project 0.20.53](https://github.com/wellmanifest/new-project/blob/d4dab328fbea876eadd3f4ad4e2349d520a2b15a/docs/information/local-ci-publication.md). +Source revision: `d4dab328fbea876eadd3f4ad4e2349d520a2b15a`. +Canonical document SHA-256: `58416ae05eb5513fd1c082c81c9c4be18cb8e6b2672c864bc20e9a8957401b59`. + +This managed file is an adoption reference. The authored document and its +metadata remain at the canonical Wellmanifest home; do not register this copy +as a new document owned by the adopting repository. + +For every repository, by default, prefer protected local OneDev verification +followed by the independent local Validator App. No organization list limits +this default; only `.governance/local-ci-publication.json` with +`scope.mode: "restricted"` (schema `new-project.local-ci-publication/v1`) may +narrow it, and a restriction never grants or removes authority. Resolve the actual protected profile, +observe existing reconciliation, require fresh verification of the PR head +merged with the current base, then invoke the trusted local Validator adapter +under existing publication authorization. The supported local adapter is +`subactor/validator-agent/bin/run-local-direct-pr.sh`; use its protected deployed +checkout and existing key reference as specified by the canonical runbook. +Never self-approve or merge directly. + +A hosted Actions billing or capacity error does not prove local CI is unavailable. +Use hosted dispatch only when explicitly selected by the protected deployment. +Preserve all additional repository checks and required operating systems. +Retire a hosted check only after equivalent deployed local canary evidence and +independent policy review. Missing profiles are gaps, never successful coverage. + +Keep declared, configured, deployed, verified and published evidence separate. +Read the canonical policy for the complete workflow, authority boundaries and +migration requirements. This reference grants no execution or merge authority. diff --git a/.governance/docs/SNAPSHOT_MIGRATION.md b/.governance/docs/SNAPSHOT_MIGRATION.md new file mode 100644 index 0000000..2838ee9 --- /dev/null +++ b/.governance/docs/SNAPSHOT_MIGRATION.md @@ -0,0 +1,162 @@ +--- +{ + "schema": "wellmanifest.docs/document/v1", + "id": "snapshot-migration", + "kind": "information", + "version": 3, + "title": "One-time lossless snapshot migration", + "status": "proposed", + "owner": "wellmanifest/new-project", + "scope": "repository", + "created": "2026-09-14", + "updated": "2026-09-28", + "review_after": "2026-10-05", + "source_revision": "a4178b9cf6fa12540ee7406d7f38391dd4fa1f30", + "affected_repositories": ["wellmanifest/new-project"], + "evidence": ["repo://wellmanifest/new-project/scripts/snapshot_migration.py", "repo://wellmanifest/new-project/tests/snapshot_migration_test.py"] +} +--- + +# One-time lossless snapshot migration + + +## Purpose + +Recover a published pre-adoption snapshot without inventing historical intent +or changing ordinary delivery budgets. Prefer resume when the existing contract +is valid. Split only when each independently accepted slice preserves its source +and coverage. A new snapshot migration needs its own allocated ticket and an +explicit, independently acquired authorization for that exact import. + + +## Contract and ownership + +`delivery.snapshotMigration` binds repository, accepted base, original source +commit, source tree, canonical inventory SHA-256 and an authorization reference. +The new ticket contains its ordinary repair scope and budget. Its complete intent +must be accepted before implementation. The old snapshot, authorship, timestamps +and refs remain unchanged. The standard validates a proof; the consumer owns the +actual import and adopted policy, and the protected publisher owns effects. + +Generate the complete inventory without modifying Git: + +```sh +python3 scripts/snapshot_migration.py --root CHECKOUT --base BASE_SHA --source SOURCE_SHA +``` + +The digest covers sorted entries containing path and before/after Git object ID +and file mode, including additions, removals and symlinks. Gitlinks and ambiguous +paths are refused. This observation has no authority. The protected grant names +the exact implementation paths eligible for import accounting. Its length is the +approved import count; it never becomes a repository-wide limit. + + +## Lossless import and new work + +Allocate a fresh ticket with a canonical branch/worktree from the exact approved +base. Record its README and intent before writing the imported implementation. +Create one merge commit whose first parent is that base and whose second parent +is the exact preserved source. Its tree must be byte/mode identical to the source +except for the new ticket's metadata, which includes the accepted intent and +README. Do not run a merge experiment on the predecessor branch. A conflicting +or interrupted import remains a local recovery operation; preserve both parents +and stop before publication. + +The exact paths proven unchanged against the protected grant are historical +context when selecting the new ticket and checking changed-path ownership. +They do not need to be claimed in its ordinary repair scope. The scope patterns +it does declare must still belong to its workstream. This distinction grants no +ownership to change an imported file, close an old ticket or take over a lease. +An import-only candidate is material delivery: it selects the current migration +ticket and still requires ordinary independent approval for the exact subject. + +Ordinary follow-up commits may contain only separately authorized repairs. A file +changed from the snapshot consumes the ordinary repair budget, even when that +change restores the original base contents. Working-directory repairs also stop +qualifying as unchanged imports. Component ownership, allowed paths, secret +scanning, immutable adoption, tests and independent review remain required. +Only history already reachable from the proven source is excluded from the new +ticket's chronology check. Equal trees with missing ancestry do not qualify. + +## Managed allocation before publication + +Run the independently installed immutable package's `project/new-ticket.sh` +from the target checkout, with `--workstream ID --snapshot-proposal REQUEST`. +The closed `new-project.snapshot-proposal-request/v1` request contains a stable +`requestId`, repository, exact base/source, target branch, canonical slug and a +complete `intentTemplate` without a manual ticket ID or migration contract. +The allocator reserves the next ID under the clone-wide lock/high-water mark, +returns the proposal path and digest, and creates no branch, checkout or lease. +Changed input under the same request ID is rejected; interrupted reservations +are preserved. This proposal grants no authority. + +After the protected consumer has reviewed the exact import and acquired its +own controller lease, invoke `--snapshot-materialize REQUEST`, +`--recovery-lease-store DIR`, `--migration-authorization FILE` and +`--migration-authorization-sha256 SHA`. The closed materialization request binds +`proposalPath`, `proposalSha256`, lease ID, revision, fencing token and owner +actor/session. The controller must already hold an unexpired editing lease for +the exact repository, branch, worktree, scope and complete plan bytes. +Authority inputs must reside outside all registered checkouts and Git metadata. +The standard neither issues a grant nor acquires or renews a lease. + +The installed package supplies executable helpers; target files supply target +policy and source data. An isolated index builds the unpublished import object. +The managed checker validates the detached candidate before a branch can be +created, then rechecks refs, peer dirty state, proposal, protected grant and +controller CAS. Source branches and the primary index remain intact. A failure +preserves the detached candidate and reservation for reconciliation; it never +pushes, approves, merges or consumes a grant. Local single-clone file storage is +the supported adapter; registered and SQLite allocation retain their own +protected controllers. + + +## Protected authorization and single use + +The authorization schema is `new-project.snapshot-migration-authorization/v1`, +registered with the contract in `governance/snapshot-migration.schema.json`. +It binds grant ID, repository, ticket, exact source contract and complete intent +digests, head branch, target branch, accepted base and the implementation path +allowlist. Explicit `historicalTickets` name unchanged source metadata that this +candidate treats as history; this does not close tickets or transfer a live lease. +`maxUses` is exactly one. A consumed grant is rejected. + +The checker requires `--migration-authorization` outside the candidate checkout +and `--migration-authorization-sha256` from independently protected configuration. +It also requires `--expected-repository` and `--migration-branch`; the latter is +the authenticated PR head branch, including in detached merge-result jobs. A +candidate-provided file, digest, command-line override or Markdown approval is +not a trusted grant. Infrastructure must provision these inputs through its +existing protected policy process before admitting a migration. Never derive +the expected digest from the same untrusted file at execution time. + +The trusted caller supplies the freshly observed target as `--base` and tests +its exact candidate/merge result as `--head`. A different current base rejects the +single-use contract, including a second publication after the first merge. The +protected publisher must reobserve that base immediately before merge, serialize +its existing grant transaction and record consumption in its external journal. +A timeout requires readback of the same transaction before another effect. This +read-only checker neither operates that journal nor manufactures approval. A +publisher unable to enforce consumption must refuse migration publication. + + +## Validation and adoption + +Run `python3 tests/snapshot_migration_test.py`, the existing governance regression +suite, package/adoption checks and the managed gate. Fixtures cover changed pins, +foreign subjects, additional import files, missing source history, missing intent, +consumed grants, changed bases, dirty repairs, import-only approval rejection, +wrong-head reviews and unchanged ordinary scope, ownership and budgets. +Passing fixtures proves the standard implementation only. A consumer still needs +the independently published immutable package, supported CI pin, a real grant, +full application tests and a protected exact-head result before review and merge. + + +## Limits and rollback + +This package adds no production grant service and no implicit grant transport. +It does not authorize a migration solely because its inventory is valid. A +specific consumer's historical findings remain in its own repository and cannot +be dismissed by this document. Source, package, adoption, deployment, canary and +publication remain distinct stages. Roll back through an independently reviewed +successor package while retaining the original source and recovery history. diff --git a/.governance/error/GOV-AGENT-HOST.md b/.governance/error/GOV-AGENT-HOST.md new file mode 100644 index 0000000..2f6cbba --- /dev/null +++ b/.governance/error/GOV-AGENT-HOST.md @@ -0,0 +1,80 @@ +# GOV-AGENT-HOST: kontrakt host-agnostyczny nie jest aktywny + +Kody: `GOV-AGENT-HOST-001`, `GOV-AGENT-HOST-002`, `GOV-AGENT-HOST-003`, +`GOV-AGENT-HOST-007` +emitowane przez `.githooks/pre-commit`, oraz `GOV-AGENT-HOST-004`, +`GOV-AGENT-HOST-005`, `GOV-AGENT-HOST-006` emitowane przez +`.governance/agent_host_check.py`. Wszystkie siedem jest zarejestrowanych w +`governance/diagnostics.json`; `scripts/audit_diagnostics.py` skanuje teraz +również `.githooks`, więc kod emitowany przez hooka nie może już wypaść z +katalogu niezauważony. + +Ten sam audyt emituje `GOV-AGENT-HOST-004`, gdy instrukcje przekraczają limit +hosta, tracą `checkpoint`/`handoff`/`stop`, zawierają skonfigurowaną sprzeczność +albo deklarują required check, którego workflow nie publikuje. To są blokery +przed rozpoczęciem długiej sesji, a nie sygnały do kolejnych ślepych retry. + +W repozytorium adoptującym standard checker jest instalowany jako +`.governance/agent_host_check.py`; poniższe polecenia dotyczą tego układu. +W źródłowym hubie `wellmanifest/new-project` użyj zamiast tego +`python3 scripts/agent_host_check.py --root .` (z opcją `--format json` +przy weryfikacji JSON). + +## Situation + +`001`–`003` oraz `007` pojawiają się przy commicie: branch nie jest związany z ticketem +`IN_PROGRESS`, brakuje `project/ticket-NNN/README.md` w stagowanej migawce albo +commit próbuje zapisać terminalne zamknięcie w repozytorium lub zawiera +wyłącznie nośniki śledzenia ticketu bez materialnego rezultatu. + +`004`–`006` pojawiają się w bramie: brakuje pliku instrukcji deklarowanego przez +`agent-hosts.json`, hook nie istnieje lub nie jest wykonywalny, albo +`core.hooksPath` nie wskazuje na katalog zarządzanego hooka. + +## Meaning + +Plik instrukcji (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, reguła Cursora, +`.aider.conf.yml`, instrukcje Copilota) jest sugestią dla modelu — każdy host +może go zignorować. Jedyne warstwy, które nie są sugestią, to hook gita, +`governance / enforce` w CI oraz lifecycle paczek. `GOV-AGENT-HOST-006` +oznacza, że repozytorium wygląda na zgodne, ale żaden commit nie jest w +rzeczywistości sprawdzany. + +## Safe resolution + +1. Aktywuj hooka w klonie: `git config core.hooksPath .githooks` oraz + `chmod +x .githooks/pre-commit`. Wartość `core.hooksPath` pochodzi z + `agent-hosts.json` (`hook.hooksPathConfig`), nie z konwencji. +2. Gdy brakuje plików hostów, zbootstrapuj je z huba: + `./scripts/install-agent-hosts.sh --source --target `, albo + zaadoptuj bieżącą wersję standardu (`scripts/create_adoption_lock.py`). +3. Dla `001`, `002` i `007` zaalokuj ticket przez `./project/new-ticket.sh`, + przełącz się na branch zawierający `ticket-NNN`, ustaw status `IN_PROGRESS` + i dołącz materialną zmianę poza katalogiem ticketu, TODO, indeksem i + registry. Dla `003` nie twórz commita: chroniony kontroler dostawy zapisuje + terminalny receipt poza checkoutem autora. +4. Potwierdź stan: `python3 .governance/agent_host_check.py --root .`. + Przy findingu anomalii napraw źródłowy kontrakt/projekcję lub CI, a potem + uruchom audyt ponownie; nie obchodź go przez `--no-verify`. + +## Verification + +- `python3 .governance/agent_host_check.py --root .` kończy się kodem 0. +- `git config --get core.hooksPath` zwraca wartość z `agent-hosts.json`. +- Próbny commit na branchu bez ticketu jest odrzucony przez hook. +- `./project/governance-check.sh` nie zgłasza kodów `GOV-AGENT-HOST-*`. + +## Do not + +- Nie usuwaj hooka ani nie commituj z `--no-verify`, żeby przejść bramę. +- Nie dopisuj reguły do Markdown zamiast naprawy mechanizmu; Markdown nie jest + substytutem hooka. +- Nie ustawiaj `core.hooksPath` na katalog spoza standardu. +- Nie edytuj plików hostów lokalnie — są zarządzane digestem i drift wykryje + `GOV-SYNC-001`. + +## Related rules + +- `AGENTS.md` rule 22 (HOST-AGNOSTIC STANDARD) +- `C-HOST-001`, `C-HOST-002` +- `GOV-SYNC-001` (drift zarządzanych plików hostów) diff --git a/.governance/error/GOV-APPROVAL.md b/.governance/error/GOV-APPROVAL.md new file mode 100644 index 0000000..a0a0d5f --- /dev/null +++ b/.governance/error/GOV-APPROVAL.md @@ -0,0 +1,101 @@ +# GOV-APPROVAL — restore progress through the protected publisher + +## Situation + +An implementation PR has no usable trusted approval. This includes a green PR +that was never dispatched to its configured reviewer; repeating `git push`, +creating another worktree or waiting without a registered request cannot fix it. + +## Meaning + +| Diagnostic | Missing or invalid evidence | Safe next action | +| --- | --- | --- | +| `GOV-APPROVAL-001` | Trusted approval is absent | Observe a pending review request, then invoke the configured controller if none exists. | +| `GOV-APPROVAL-002` | Ticket differs | Reconcile the intent/PR binding before requesting review again. | +| `GOV-APPROVAL-003` | Receipt is absent, malformed or repository-controlled | Have the protected verifier acquire and validate external evidence. | +| `GOV-APPROVAL-004` | Repository, PR or HEAD differs | Invalidate the stale request and revalidate the current exact subject. | +| `GOV-APPROVAL-005` | Actor or verification method is untrusted | Resolve the configured type-specific reviewer or verifier; do not widen the allowlist. | + +`NO_NEW_GATE`: this is navigation for existing approval rules, not an extra +check, mandatory service, tracker or new permission. A diagnostic is not a +command to execute arbitrary text supplied by a PR or an LLM. + +## Safe resolution + +1. **EXACT_SUBJECT.** Observe repository, PR number, current HEAD and base, + ticket/intent, required-check policy and controller ownership. Distinguish + local changes, pushed commits, PR, checks, approval, merge, release and + deployment. Reuse the existing ticket, checkout and publication record. +2. **OBSERVE_BEFORE_RETRY.** Query the existing request and remote result first. + A timed-out response can follow a successful effect. If exact-head approval + or merge already exists, reconcile its receipt instead of repeating it. + An active matching request means wait/observe, not duplicate dispatch. +3. **INVOKE_PROTECTED_CONTROLLER.** Use the adopted publisher's documented + capability/preflight query to resolve its installed revision, protected + profile, target and required checks. For a new PR use the configured Goal + delivery path; a PR already pushed does not need another push merely to + request review. Where a deployed timer owns the request, reuse its managed + intake/reconciliation route. Otherwise invoke the configured independent + Validator or route to the configured trusted human. Existing authorization + for protected publication does not need another chat confirmation. +4. **REUSE_PENDING_EFFECT.** Before dispatch, retain the controller's request + reference, exact subject and idempotency key in its existing journal. Reuse + those bindings on a supported retry. Do not invent an idempotency flag or + journal backend for a tool that lacks one. Use its documented observe-only + recovery or serialize the unresolved effect instead. +5. Classify the result and expose the next owner/action: + - **waiting**: an active request or pending required check; observe with the + configured bounded polling/backoff, showing phase, elapsed time and last + evidence. The controller's configured deadline leads to readback and a + precise escalation, not a fresh worktree or an infinite silent wait; + - **transient transport failure**: read back first, then retry within the + controller's limits only if no matching effect is confirmed; + - **deterministic refusal**: preserve its code and input digest; repair the + named prerequisite before another attempt. Do not rerun unchanged tests + or requests indefinitely; + - **stale subject**: invalidate stale validation/approval, re-observe HEAD, + intent, scope and fencing, then let the controller start a new exact-head + request; never mutate a branch while it is frozen; + - **missing profile, identity or authority**: report the specific prerequisite + and responsible operator. Continue authorized disjoint work; do not + replace the protected route with raw `gh` approval/merge. +6. Read back the controller receipt and GitHub state. A zero exit status alone + proves neither review nor merge. When merge is confirmed, let the protected + controller close the ticket externally. Do not add a repository closure + commit. Release only the owned reservation through its managed lifecycle; + preserve unknown worktrees and other writers. + +If no declared controller or trusted reviewer can be resolved, the dependent +merge remains blocked with a concrete remediation. This runbook does not +install a substitute, invent authority or require new repository files merely +to report that condition. + +## Verification + +- Approval binds the current repository, PR, HEAD and ticket. The protected + verifier checks the actor type and allowlist, or signature and issuer. +- Required checks satisfy the protected policy for the exact change. Optional + observations remain visible but do not become required by this runbook. +- A merge claim has both the protected result and remote merged state with + the matching head/merge SHA; a review-only result remains review-only. +- **MERGE_IS_NOT_RELEASE.** Verify a released artifact/version/digest and the + deployed runtime separately before claiming the application is current. +- Source validation: `python3 scripts/audit_diagnostics.py --root .` and + `bash tests/governance-validator.test.sh` in the declared hub environment. + These validate navigation and governance regressions, not remote authority. + +## Do not + +- **NO_SELF_APPROVAL:** the author invokes the protected boundary; it does not + approve or merge its own work directly, forge a receipt or edit allowlists. +- Do not accept green CI, advisory LLM text, an HTTP 200, a process exit code, + a local ticket status or elapsed time as evidence of approval or completion. +- Do not bypass a failed required check or weaken policy to unblock this PR. +- Do not delete unknown work, reset a retry journal, allocate duplicate work, + or rewrite history to obtain a fresh-looking publication attempt. + +## Related rules + +`P-CORE-008`, `P-CORE-015`, `P-CORE-018`–`P-CORE-021`, `P-LEASE-003`, +`P-RECOVERY-001`, `C-PUBLISH-003`, `C-PUBLISH-006`, `C-PUBLISH-008`, +`C-PUBLISH-009`, `C-TICKET-014`, `C-TICKET-018`–`C-TICKET-020`. diff --git a/.governance/error/GOV-ARCHITECTURE-001.md b/.governance/error/GOV-ARCHITECTURE-001.md new file mode 100644 index 0000000..d258c52 --- /dev/null +++ b/.governance/error/GOV-ARCHITECTURE-001.md @@ -0,0 +1,68 @@ +# GOV-ARCHITECTURE-001: reconcile architecture ownership + +## Situation + +An implementation declares data changes outside the integration workstream. + +## Meaning + +This finding is a routing or contract error, not a request for a second user +approval. Observe the actual diff and existing execution authorization first. + +## Safe resolution + +1. Check whether responsibility really moves between components. Changing an + implementation within its existing owner is not a responsibility transfer. + Do not clear a true transfer merely to pass validation. +2. Classify each data change. `component-local-state` means private state such + as a deployment journal or cache owned by one declared component. It does + not cover business database migrations, shared schemas, import/export or + transfers of data ownership. Bind the record to exactly one declared + component by its name. +3. `schema-migration`, `cross-component-migration`, `ownership-transfer`, + `unknown` and legacy prose require the integration workstream. A mixed list + is integration-owned if any item requires integration. A local-state record + never overrides `responsibilityChanges=true` or integration-required paths. +4. For a classification error, correct the contract under the existing scope + and current lease, retaining the reason and validation evidence. For a real + integration change, resolve the target manifest's integration owner and + reuse or allocate the appropriate ticket through the managed allocator. + `integrationTicket` alone does not transfer path ownership. +5. If another writer owns the same files, preserve its work. Prepare a patch + and isolated regression tests; apply it only after an accepted handoff or + serialization. Unknown ownership is not inferred from idle time; only the + measured `GOV-IDLE-TAKEOVER` policy may move a known lease to a new writer. +6. Revalidate intent and lease before writes. Validate the full delivery diff + with the exact observed base and head before publication; a check of an + empty worktree does not validate the PR. Keep protected review unchanged. + +## Verification + +Run the managed gate against the actual full base/head diff. Verify that the +component exists and the source paths still belong to the selected workstream. + +Example (the component must also be present in `architecture.components`): + +```json +{ + "kind": "component-local-state", + "component": "DisplayNet artifact deployment", + "description": "Private retained-image journal and failed-release quarantine" +} +``` + +## Do not + +Do not relabel migrations as local state or clear true responsibility transfers. + +Older adopters intentionally reject typed records. Publish and adopt the +versioned checker, schema, diagnostic and this runbook together through the +managed package mechanism before using them in a target ticket. Do not patch +an adopted checker by hand or mark a candidate package as an approved release. + +## Related rules + +- `GOV-INTEGRATION-001`: shared-path ownership remains enforced. +- `GOV-SCOPE-001`: a classification does not expand allowed paths. +- `P-CORE-008`: existing session authorization permits bounded execution. +- `P-CORE-009`: reuse the matching authorized ticket. diff --git a/.governance/error/GOV-CHANGE-LEASE.md b/.governance/error/GOV-CHANGE-LEASE.md new file mode 100644 index 0000000..968c740 --- /dev/null +++ b/.governance/error/GOV-CHANGE-LEASE.md @@ -0,0 +1,77 @@ +# GOV-CHANGE-LEASE + +## Situation + +`GOV-CHANGE-LEASE-001` through `004` mean repository or publication authority +cannot be established from a closed, monotonic change lease. + +## Meaning + +- `001`: malformed, unknown or non-closed lease, request or receipt. +- `002`: stale CAS/fencing authority or non-monotonic receipt trace. +- `003`: illegal transition, changed frozen HEAD or invalid freeze state. +- `004`: supersession lacks accepted terminal replacement evidence. + +## Safe resolution + +For a completed delivery under `local-single-clone`, the current owner may +explicitly hand off its valid lease through the managed POSIX controller: + +```sh +python .governance/terminal_lease_release.py --root . --request /absolute/request.json +python .governance/terminal_lease_release.py --root . --request /absolute/request.json --apply +``` + +The closed request has `schema: new-project.terminal-owner-release/v1`, +`requestId`, canonical `worktreeId`, current `ownerActor` and `ownerSession`, +`ownerHandoff: true`, `expectedRevision`, `expectedFencingToken`, the exact +`expectedLeaseSha256` and `terminalReceiptRef`. The receipt must already be in +the configured registry. Actor/session identify the invoking owner; never copy +another session's identity from its lease. The merged receipt must verify in +the configured Git-common terminal registry and bind this ticket's merged +HEAD against its current target. This is a resource handoff, not publication +approval. A dry run grants no authority. +The invoking session supplies `NEW_PROJECT_WRITER_ACTOR` and +`NEW_PROJECT_WRITER_SESSION`; applying requires them to match the request and +the current lease. The controller API takes that caller context separately. + +The controller rechecks owner, bytes, fencing, clean delivery checkout and +source ancestry, uses canonical transitions and increments fencing. An active +local claim is explicitly cancelled before release; an already approved or +merged lease follows its existing terminal transitions. No approval phase is +invented. A write-ahead receipt makes an interrupted request replayable with +the same request, without repeated transitions. Re-observe after any CAS loss. + +This adapter preserves every worktree and branch. Registered controllers, +frozen publication still awaiting reconciliation, unknown/foreign owners, +expired phases and invalid states such as `completed` need their existing +controller. An expired timestamp alone never permits taking ownership or +normalizing the record. Unsupported platforms refuse before controller effects. + +1. Stop the writer; never retry with guessed counters. +2. Read the authoritative lease from the repository change controller. +3. Use its exact revision, fencing token, phase and HEAD. +4. For replacement, attach its accepted terminal receipt. + +## Verification + +```bash +python3 .governance/change_lease_check.py validate .governance/change-lease.json +python3 .governance/change_lease_check.py trace .governance/change-lease-events.jsonl +``` + +Expected result: `GOV-CHANGE-LEASE-PASS`. + +## Do not + +- Do not reuse a revision or fencing token. +- Do not change branch, PR or HEAD after `publication_frozen`. +- Do not infer success from a closed PR or absent worktree. +- Do not store credentials, tokens or raw diffs in lease evidence. + +## Related rules + +- `wellmanifest/poa`: monotonic revision and independent admission. +- `wellmanifest/logs`: correlation, causation, hashes and receipt references. +- `wellmanifest/dsl`: controlled effects require external authority. +- `wellmanifest/new-project`: exact-head publication and terminal cleanup. diff --git a/.governance/error/GOV-HOST-MERGE.md b/.governance/error/GOV-HOST-MERGE.md new file mode 100644 index 0000000..d7f5e3a --- /dev/null +++ b/.governance/error/GOV-HOST-MERGE.md @@ -0,0 +1,43 @@ +# GOV-HOST-MERGE: przestarzały blok autonomicznego scalania w instrukcjach hosta + +Kod: `GOV-HOST-MERGE-001` (ostrzeżenie) emitowany przez `governance_check.py` +i zarejestrowany w `governance/diagnostics.json`. + +## Situation + +`AGENTS.md`, `CLAUDE.md` lub `GEMINI.md` w katalogu głównym repozytorium zawiera +blok `` w starej treści, powołującej +się na `wellmanifest/merge@ticket-008` („Prymat Zielonych Testów”). + +## Meaning + +Stary blok pozwala agentowi scalać PR po zielonych testach, rotować konta +recenzentów, używać `gh pr merge --admin` i luzować limity WIP. Obowiązujący +standard dopuszcza wyłącznie niezależnego Validatora do zatwierdzania i +scalania; agent czeka na limity API i nie omija reguł. Agenci czytający stary +blok scalali PR samodzielnie (issue #507). + +## Safe resolution + +1. Przydziel ticket w repozytorium, którego plik dotyczy; nie edytuj pliku na `main`. +2. Podgląd: `python3 /scripts/host_publication_policy.py --path + --expected-sha256 ` (bez `--apply`); sprawdź `changed`. +3. Zastosuj z kopią zapasową (`--apply --backup-directory `) + w worktree ticketu, zatwierdź i opublikuj przez zwykły PR i Validatora. +4. Plik hosta spoza Git (np. `~/github/subactor/AGENTS.md`) uzgadnia jego właściciel + z kopią zapasową; nie twórz w tym celu repozytorium. + +## Verification + +`./project/governance-check.sh` nie zgłasza `GOV-HOST-MERGE-001`, a plik zawiera +zdanie „Only the independent Validator may approve and merge.”. + +## Do not + +- Nie usuwaj znaczników bloku ani nie przepisuj go ręcznie poza narzędziem. +- Nie scalaj PR samodzielnie, nie używaj `--admin` i nie zmieniaj kont recenzentów. +- Nie stosuj zmiany do wielu repozytoriów jednym commitem ani bez ticketu. + +## Related rules + +`GOV-HOST-MERGE-001`, `GOV-AGENT-HOST-001`, `GOV-PUSH-001`; issue #507. diff --git a/.governance/error/GOV-IDLE-TAKEOVER.md b/.governance/error/GOV-IDLE-TAKEOVER.md new file mode 100644 index 0000000..087beac --- /dev/null +++ b/.governance/error/GOV-IDLE-TAKEOVER.md @@ -0,0 +1,150 @@ +# GOV-IDLE-TAKEOVER + +## Situation + +Another agent's ticket blocks the next task, but its writer has stopped. The +idle takeover policy (`new-project.idle-takeover/v1`) lets an autonomous agent +continue that ticket after `coordination.idleTakeover.idleMinutes` without +observed activity, and keeps a board of every ticket's state for the next agent. + +## Meaning + +- `GOV-IDLE-TAKEOVER-001`: the takeover was refused by policy or state. +- `GOV-IDLE-TAKEOVER-002`: the observation or a snapshot, receipt, lease or + board write failed. + +### Policy (single source: the governance manifest) + +```json +"coordination": { + "idleTakeover": { "enabled": true, "idleMinutes": 15, "leaseMinutes": 120 } +} +``` + +The default and hub manifests carry these values; the manifest schema bounds +them (`idleMinutes` 5–240, `leaseMinutes` 15–480 and greater than +`idleMinutes`). The script has no built-in threshold and no validator of its +own: it applies the pinned `governance_check.idle_takeover_policy_valid`, so a +missing or malformed policy (non-boolean `enabled`, a boolean, zero, negative or +out-of-range duration) reports `POLICY_DISABLED` and nothing is taken over. + +### Activity + +A ticket is idle only when the newest of these is older than `idleMinutes`: + +- the lease `heartbeatAt`; +- the ticket branch commit; +- the newest modification of a dirty or untracked file in the ticket worktree; +- the ticket's latest checkpoint in the stream that `.subactor/manifest.json` + declares as `continuity.eventStreamPath`. + +A live process whose working directory is inside the ticket worktree always +makes the ticket `ACTIVE` (observed through `/proc`; other platforms rely on the +four timestamps). + +### Worktree layout (wellmanifest/worktrees v5) + +Layout roots come from the pinned `change_lease_check.py` (`WORKTREES_ROOT`, +`LEASE_ROOT`); nothing else defines them. The primary checkout is the first +entry of Git's worktree registry, also when the command runs inside a linked +worktree. A ticket is a takeover candidate only when one delivery unit matches: + +- worktree `/.worktrees/--`; +- lease `/.subactor/leases/--.json`; +- branch `ticket/NNN-` and the lease's `ticketId`; +- no symlink in the canonical worktree path. + +Anything else is `NONCANONICAL_LAYOUT` with the failing parts in +`layoutProblems`. Request an adoption upgrade; do not move or recreate it by hand. + +### States + +| State | Meaning | Action | +| --- | --- | --- | +| `ACTIVE` | Activity within `idleMinutes` or a live process. | Assist read-only or choose other work. | +| `IDLE_TAKEOVER_ELIGIBLE` | Idle, canonical, takeable phase (`claimed`, `editing`, `validating`, `expired`), worktree present. | `idle_takeover.py takeover`. | +| `IDLE_FROZEN` | Publication frozen, dispatching or approved. | Never taken over; follow the PR's validator outcome. | +| `IDLE_INTEGRATED` | Clean worktree whose HEAD is already in the target branch. | Release the lease; nothing to continue. | +| `IDLE_NO_WORKTREE` | Lease without a registered ticket worktree. | Reconcile; never taken over. | +| `IDLE_NOT_TAKEABLE` | Idle in a phase the lease contract cannot expire or release. | Reconcile through the controller. | +| `POLICY_DISABLED` | No enabled manifest policy, so idleness is not measured (a live process still reads `ACTIVE`). | Accepted handoff only. | +| `NONCANONICAL_LAYOUT` | Worktree, lease or branch outside the v5 layout. | Adoption upgrade; never by hand. | +| `INVALID_LEASE` | The repository's pinned lease contract rejects the lease. | Restore controller evidence. | + +Phase sets are derived from the pinned contract's transitions, not listed here. + +## Safe resolution + +### Takeover + +```sh +python3 .governance/idle_takeover.py observe # rewrite and print the board +python3 .governance/idle_takeover.py takeover --ticket ticket-NNN \ + --actor agent: --session +``` + +`takeover` takes an exclusive, non-waiting lock on +`.json.lock` (a held lock refuses), re-observes under it and refuses +anything that is not `IDLE_TAKEOVER_ELIGIBLE` or already held by the same +session. Still under the lock it: + +1. stores dirty work as a content-addressed tar (tracked diff plus every dirty + or untracked file) in `.subactor/snapshots/idle-takeover//.tar`; + the worktree is not reset, so the new owner continues from the same files; +2. moves the lease through the pinned change-lease CAS: `expire` (unless already + expired), `release`, then a new `claimed` lease for the new actor and session + with revision and fencing token one above the release receipt and + `leaseMinutes` until expiry; +3. compares the lease bytes and file identity immediately before the receipt and + the replacement and refuses on any concurrent change; a writer that bypassed + the lock still fails the fencing token at its next commit; +4. appends the record to `.subactor/receipts/idle-takeover/.jsonl`; +5. rewrites the board and prints the record as JSON. + +The JSON result names `ticket`, `branch`, `worktreePath`, `leasePath` and +`writerEnvironment` (`NEW_PROJECT_WRITER_ACTOR`, `NEW_PROJECT_WRITER_SESSION`, +`NEW_PROJECT_LEASE_REVISION`, `NEW_PROJECT_FENCING_TOKEN` for the writer hook). +Take these only from the result (worktrees rules 9–10), export the environment, +and work only inside `worktreePath`. A non-zero exit ends the attempt. + +The authority reference is +`policy:new-project.idle-takeover/v1;idleMinutes=…;limit=…;lastActivity=…`. +It authorizes continuing the ticket's existing intent and scope only. It never +grants review, approval, merge, a scope change or a takeover of a frozen +publication; the independent Validator remains required. + +### Autonomy board + +Every `observe`, `takeover` and `work_start_check.py` run rewrites +`.subactor/cache/autonomy-board.json` and `.md` in the primary checkout (the +root-ignored cache namespace). The header names the worktree and lease roots +and links the repository, its pull requests and its issue list. Each ticket +lists state, owner, phase, idle minutes, the newest activity source, dirty file +count, its worktree path and links to the branch, README and pull request. Read +it before choosing the next task. The board is an observation and grants no +authority. + +## Verification + +```sh +python3 tests/idle_takeover_test.py +``` + +## Do not + +- Do not take over a ticket that is not `IDLE_TAKEOVER_ELIGIBLE`, or treat lease + expiry, an old commit or a missing heartbeat alone as a handoff. +- Do not reset, clean, move or recreate the previous writer's worktree; the + snapshot is evidence, not a replacement for the files. +- Do not hand-write a lease, reuse a revision or fencing token, or copy the + previous holder's actor or session. +- Do not take paths from a directory listing; use only the JSON result. +- Do not treat the takeover as review, approval, merge or scope authority. + +## Related rules + +- `GOV-WORK-START`: routes and accepted handoff. +- `GOV-CHANGE-LEASE`: CAS transitions, fencing and receipts. +- `wellmanifest/worktrees` v5: canonical worktree and lease layout, rules 1–13. +- `wellmanifest/nohardcode` and `wellmanifest/ssot`: the policy lives in the + manifest and the layout in the lease contract; the script repeats neither. diff --git a/.governance/error/GOV-INTENT.md b/.governance/error/GOV-INTENT.md new file mode 100644 index 0000000..278d9a6 --- /dev/null +++ b/.governance/error/GOV-INTENT.md @@ -0,0 +1,80 @@ +# GOV-INTENT + +## Situation + +Kody `GOV-INTENT-001`–`GOV-INTENT-003` oznaczają, że diff implementacyjny nie +ma ważnego mandatu. Kolejno: ticket nie był w stanie implementacyjnym, +`intent.json` jest nieobecny lub niepoprawny, albo intent nie istnieje w drzewie +pierwszego materialnego commita. + +## Meaning + +Intent jest planem, który kontroler zapisuje i waliduje **zanim** rozpocznie +edycję. Granica czasowa dotyczy sesji wykonawczej, nie wymaga osobnego commita. +Intent może wejść atomowo z pierwszą materialną zmianą, o ile znajduje się w +drzewie tego commita i wcześniejsza autoryzacja sesji wiąże dokładnie ten zakres. + +`GOV-INTENT-003` jest osobnym faktem od `GOV-SCOPE-001`. Zakres może być +idealnie zgodny z `allowedPaths`, a mandat i tak nie powstał na czas. + +## Safe resolution + +1. Zbuduj jeden atomowy commit zawierający intent i materialną zmianę: + + ``` + feat|fix|chore(ticket-NNN): intent + material implementation + ``` + + Kontroler najpierw waliduje `intent.json` i session authorization, następnie + edytuje implementację, a na końcu commit obejmuje oba elementy. + +2. Po trusted merge nie edytuj repozytorium. Chroniony kontroler zapisuje + zewnętrzny receipt terminalny związany z PR head, merge SHA i checks. + +3. Gdy kod został zacommitowany przed intentem, przebuduj nieopublikowaną gałąź + atomowo. Nie przepisuj opublikowanej, zaufanej historii. + +4. Gdy błąd `delivery ... incomplete` lub `bounded-delivery fields` zawiera + `move X to PATH`, przenieś pole dokładnie do wskazanej ścieżki zamiast + przepisywać intent. Pola architektury (`interfaceChanges`, `dataChanges`, + `components`, `ui`, `rollback`, `status`, `decision`, + `responsibilityChanges`) należą do `delivery.architecture`, limity `max*` do + `delivery.budgets`, a pozostałe pola kontraktu bezpośrednio do `delivery`. + Przeniesione pole nie jest akceptowane jako synonim; nieznane pola pozostają + odrzucone bez podpowiedzi położenia. + +## Verification + +`./project/governance-check.sh` bez `--base` na gałęzi `ticket/NNN-*` sam +wyznacza bazę jako `git merge-base HEAD origin/` (target z +`delivery.targetBranches` manifestu, a przy kilku z `delivery.targetBranch` +ticketu) i bada zacommitowany zakres, więc brak kontraktu `delivery` jest +wykrywany lokalnie. Brak `origin/` kończy się `GOV-BASE-001` zamiast +pustego `GOV-PASS`; wykonaj `git fetch origin ` albo podaj `--base`. +Jawne `--base` lub `--changed-file` zawsze wygrywa. Na innych gałęziach i przy +odłączonym HEAD wywołanie bez argumentów nadal bada tylko drzewo robocze. +Chroniona bramka zakresowa potwierdza, że intent istnieje w pierwszym +materialnym commicie. + +Dowodem jest wyłącznie tryb zakresowy: + +```sh +./project/governance-check.sh --base origin/main --head HEAD +``` + +Uruchom go przed każdym pushem gałęzi ticketowej. Wynik `GOV-PASS` z gołego +wywołania poza gałęzią `ticket/NNN-*` nie jest wystarczającym dowodem. + +## Do not + +- Nie traktuj `GOV-PASS` z domyślnego, bezargumentowego wywołania jako dowodu + poprawnej kolejności commitów. +- Nie przepisuj opublikowanej, zaufanej historii. +- Nie zamykaj ticketu na niescalonej gałęzi pełnego diffu. +- Nie twórz osobnego plan-only commita ani closure commita. + +## Related rules + +- `P-CORE-008`, `P-CORE-014`, `P-CORE-023` +- `C-TICKET-008`, `C-TICKET-017` +- `C-PUBLISH-003`, `C-PUBLISH-009` diff --git a/.governance/error/GOV-PACKAGING.md b/.governance/error/GOV-PACKAGING.md new file mode 100644 index 0000000..358de40 --- /dev/null +++ b/.governance/error/GOV-PACKAGING.md @@ -0,0 +1,73 @@ +# GOV-PACKAGING: metadane paczki nie egzekwują standardu + +Kody: `GOV-PACKAGING-001`, `GOV-PACKAGING-002`, `GOV-PACKAGING-003` +(emitowane przez `.governance/agent_host_check.py`). + +W repozytorium adoptującym standard checker jest instalowany jako +`.governance/agent_host_check.py`; poniższe polecenia dotyczą tego układu. +W źródłowym hubie `wellmanifest/new-project` użyj zamiast tego +`python3 scripts/agent_host_check.py --root .` (z opcją `--format json` +przy weryfikacji JSON). + +## Situation + +Repozytorium ma marker stacku (`pyproject.toml` lub `package.json`), ale: + +- `001` — brak bloku `[tool.wellmanifest]` / klucza `"wellmanifest"` albo brak + w nim pola `standard`, `revision` lub `gate`; +- `002` — blok deklaruje inną wersję/rewizję standardu niż + `.governance/manifest.lock.json`, albo wskazuje nieistniejącą bramę; +- `003` — lifecycle paczki nie uruchamia bramy: `scripts.prepare` w + `package.json` nie wywołuje instalatora hostów, a `addopts` w + `[tool.pytest.ini_options]` nie ładuje pluginu governance. + +## Meaning + +`npm install` i `pytest` wykonują się i tak — również wtedy, gdy agent nie +przeczytał żadnego pliku instrukcji. To jedyny punkt w cyklu pracy, w którym +standard można narzucić bez współpracy modelu. `001`/`002` to utrata +widoczności wersji standardu w metadanych paczki; `003` to brak faktycznej +egzekucji. + +## Safe resolution + +1. Odczytaj `standard.version` i `standard.sourceRevision` z + `.governance/manifest.lock.json`. +2. Wpisz je do bloku governance w metadanych paczki wraz ze ścieżką bramy: + + ```toml + [tool.wellmanifest] + standard = "0.18.1" + revision = "" + gate = "project/governance-check.sh" + + [tool.pytest.ini_options] + addopts = "-p wellmanifest_governance" + ``` + + ```json + "wellmanifest": { "standard": "0.18.1", "revision": "…", "gate": "project/governance-check.sh" }, + "scripts": { "prepare": "./scripts/install-agent-hosts.sh" } + ``` + +3. Po każdym upgrade standardu zaktualizuj blok razem z lockiem — rozjazd jest + wykrywany deterministycznie. +4. Potwierdź: `python3 .governance/agent_host_check.py --root .`. + +## Verification + +- `python3 .governance/agent_host_check.py --root . --format json` zwraca `ok: true`. +- `npm install` w czystym klonie ustawia `core.hooksPath` bez ręcznej komendy. +- Zmiana wersji w locku bez zmiany metadanych paczki daje `GOV-PACKAGING-002`. + +## Do not + +- Nie wpisuj wersji standardu ręcznie „na oko” — pochodzi z locka. +- Nie zastępuj lifecycle hooka dokumentacją w `README.md`. +- Nie wyłączaj `prepare` przez `npm install --ignore-scripts` w CI governance. + +## Related rules + +- `AGENTS.md` rule 22 (HOST-AGNOSTIC STANDARD) +- `C-HOST-003` +- `GOV-STACK-001` (deklarowany stack musi mieć marker) diff --git a/.governance/error/GOV-PHYS.md b/.governance/error/GOV-PHYS.md new file mode 100644 index 0000000..b1ccbdf --- /dev/null +++ b/.governance/error/GOV-PHYS.md @@ -0,0 +1,47 @@ +# GOV-PHYS + +## Situation + +`GOV-PHYS-001` and `002` mean a change reaches a file whose values carry +physical meaning, and the gate cannot see that the change was named and +planned for hardware acceptance. + +## Meaning + +- `001`: a changed path matches a contract in the adopter's + `.governance/physical-contracts.json`, but the ticket intent has no + `physicalChanges` entry for that contract. +- `002`: `physical-contracts.json` is unreadable or incomplete. The gate + fails closed until it is repaired. + +## Safe resolution + +1. List every physical property the diff changes: pin, active level, pull, + capability set, scale, range or timing. A pin move and a polarity change + are two entries. +2. For each, add `contract`, `property`, `signal`, `before`, `after` and + `acceptance` to `physicalChanges` in the ticket intent. +3. Write acceptance as observations on the device, for example "forward + switch reads inactive at rest and active while pressed". +4. When the edit keeps physical meaning, add one entry with + `property: none` and a rationale. + +## Verification + +```bash +./project/governance-check.sh +``` + +Expected result: no `GOV-PHYS-*` finding. + +## Do not + +- Do not count a unit test that asserts the new configuration as acceptance. +- Do not drop a module from a deployable profile without a `capability-set` + entry. +- Do not remove the path from the contract to silence the gate. + +## Related rules + +- `P-PHYS-001`, `P-PHYS-002`, `P-PHYS-003` in `POLICY.md`. +- [Physical interface contracts](https://github.com/wellmanifest/new-project/blob/main/docs/information/physical-interface-contracts.md). diff --git a/.governance/error/GOV-PUSH.md b/.governance/error/GOV-PUSH.md new file mode 100644 index 0000000..28622d6 --- /dev/null +++ b/.governance/error/GOV-PUSH.md @@ -0,0 +1,42 @@ +# GOV-PUSH: bezpośredni push do gałęzi docelowej + +Kod: `GOV-PUSH-001` emitowany przez zarządzany hook `.githooks/pre-push` +i zarejestrowany w `governance/diagnostics.json`. + +## Situation + +`git push` próbuje zaktualizować gałąź zadeklarowaną w +`delivery.targetBranches` (domyślnie `main`): wprost (`git push origin main`), +przez refspec (`HEAD:main`) albo usuwając ją. Repozytoria z profilem dostarczania +`main-only-planfile`, `main-only-files` lub `local-audit` są zwolnione. + +## Meaning + +Gałąź docelowa przyjmuje zmiany wyłącznie przez przejrzany pull request scalany +przez validatora. Bezpośredni push omija review, required checks i CI, które w +wielu repozytoriach uruchamia się tylko dla PR; 19.10.2026 trzynaście takich +commitów w paxlet-com/willmux pogorszyło `main` z 1 do 4 błędów i 32 wyjątków testów. + +## Safe resolution + +1. Zostaw lokalny commit na gałęzi ticketu (`ticket/NNN-...`); nie przenoś go na `main`. +2. Wypchnij gałąź ticketu: `git push -u origin ticket/NNN-...`. +3. Otwórz pull request do gałęzi docelowej i poczekaj na validatora oraz CI. +4. Jeśli commit powstał już na lokalnym `main`, utwórz z niego gałąź ticketu + (`git branch ticket/NNN-... main`) i przywróć `main` do `origin/main` dopiero + po sprawdzeniu, że gałąź ticketu zawiera całą pracę. + +## Verification + +`git push origin ticket/NNN-...` kończy się kodem 0, a pull request pokazuje +uruchomione required checks. `git push origin main` nadal kończy się `GOV-PUSH-001`. + +## Do not + +- Nie omijaj hooka (`--no-verify`, zmiana `core.hooksPath`); agentom nie wolno omijać hooków. +- Nie wymuszaj pusha (`--force`) na gałąź docelową ani nie usuwaj jej. +- Nie zmieniaj `delivery.targetBranches` ani profilu dostarczania, żeby obejść blokadę. + +## Related rules + +`C-HOST-001` (zarządzane hooki są aktywne), zasada hosta „Never commit on main”. diff --git a/.governance/error/GOV-REMEDIATION-INTENT.md b/.governance/error/GOV-REMEDIATION-INTENT.md new file mode 100644 index 0000000..c2339fc --- /dev/null +++ b/.governance/error/GOV-REMEDIATION-INTENT.md @@ -0,0 +1,57 @@ +# GOV-REMEDIATION-001/002/003/004 — invalid or inconsistent remediation intent + +## Situation + +`GOV-REMEDIATION-001` means the target-owned remediation intent is malformed or +semantically unsafe. `GOV-REMEDIATION-002` means a todo2code plan conflicts with +accepted scope, criteria, priority or preservation constraints. +`GOV-REMEDIATION-003` means the advisory overlay no longer matches the +authority-bearing intent digest. `GOV-REMEDIATION-004` means a declared task or +TODO projection is missing, differs byte-for-byte from the accepted intent, or +resolves outside the selected repository root. + +## Meaning + +The deterministic boundary cannot prove that the proposed refactoring still +implements the accepted diagnostic intent. LLM and todo2code output remains +advisory and cannot repair that authority gap by assertion. + +## Safe resolution + +1. Open the populated `remediation-intent.dsl.json` in the affected target + repository ticket; do not copy it into the Governance Hub. +2. For `GOV-REMEDIATION-001`, resolve every reported field, path, dependency, + applicability signal and verification, then validate again. +3. For `GOV-REMEDIATION-002`, reject or regenerate plans outside accepted scope. + If the objective truly changed, record a fresh bounded intent and authority. +4. For `GOV-REMEDIATION-003`, discard the stale advisory overlay and rerun + todo2code analysis against the current intent. +5. For `GOV-REMEDIATION-004`, render both declared projections atomically, + verify them before extraction and reject any path/symlink escape. +6. Give `analyze-todo2code` the exact graph, diagnostics and plan set from the + same run. It correlates `source.path` to projection record IDs and ignores + repository history that does not cite those IDs. +7. Keep unknown ownership explicit and preserve dirty worktrees or other user + state until a human classifies them. + +## Verification + +Run `validate`, then `render-todo2code --root .`, then +`verify-todo2code --root .`, and require zero exit statuses. Run +todo2code deterministically on those exact task/TODO files. Regenerated +analyzed intents must bind current intent, graph, diagnostics and plan digests, +list projection record IDs, and contain no blocking todo2code finding before +implementation proceeds. + +## Do not + +Do not edit digests by hand, suppress applicability uncertainty, infer missing +owners, widen paths from an LLM suggestion, or authorize deletion merely to +make validation pass. Do not use a target ticket or incident log as a reusable +runbook. + +## Related rules + +`C-DIAGNOSTIC-001`, `C-DIAGNOSTIC-002`, `C-DIAGNOSTIC-003`, +`C-REMEDIATION-001`, `C-REMEDIATION-002`, `C-REMEDIATION-003`, +`C-REMEDIATION-004`, `C-REMEDIATION-005`, `P-CORE-008`, `P-CORE-020`. diff --git a/.governance/error/GOV-SNAPSHOT-MIGRATION.md b/.governance/error/GOV-SNAPSHOT-MIGRATION.md new file mode 100644 index 0000000..31bc75c --- /dev/null +++ b/.governance/error/GOV-SNAPSHOT-MIGRATION.md @@ -0,0 +1,45 @@ +# GOV-SNAPSHOT-MIGRATION: bounded import proof rejected + +## Situation + +A ticket declares `delivery.snapshotMigration`, but its contract, protected grant, +Git subject, initial import or fresh target observation does not validate. + +## Meaning + +`001` is an invalid contract; `002` is an unavailable or inconsistent Git subject; +`003` is missing or mismatched protected authorization; `004` is invalid import +chronology or missing source ancestry; `005` is changed inventory/import content; +`006` is a consumed authorization, changed base or reused source ticket. +Classification does not turn a failed gate into a pass. + +## Safe resolution + +1. Reobserve the exact repository, PR branch, head, target base and original source. +2. Recompute the source inventory with the managed `snapshot_migration.py` query. +3. Preserve the predecessor and inspect the new ticket intent and import parents. +4. Have the existing protected policy boundary resolve the exact grant and its + digest. The candidate cannot select that digest or claim consumption authority. +5. Route new repairs through their ordinary approved budget. Restore the exact + import only in the owned delivery checkout, preserving pending local work. +6. If the base or grant transaction changed, reconcile its readback and obtain the + appropriate successor contract through the owner. Never replay a consumed grant. + +## Verification + +Run the managed gate with authenticated repository, branch, fresh base and the +independently pinned grant. Run every application and isolation test, then the +independent protected reviewer. Reobserve the grant journal and exact PR result +before reporting a merge or resuming an uncertain publication. + +## Do not + +Do not raise global budgets, forge old intent dates, squash away the preserved +source, create a source-less copy, disable secret scanning, omit tests, self-approve, +force-push or delete the predecessor. Do not treat a local grant file as trusted +merely because it is outside the checkout. Do not edit adopted managed copies. + +## Related rules + +P-CORE-008, P-CORE-009, C-PUBLISH-003, C-PUBLISH-008, C-LEASE-002. +See `docs/information/snapshot-migration.md` and `governance/intent.schema.json`. diff --git a/.governance/error/GOV-STANDARD-UPDATE.md b/.governance/error/GOV-STANDARD-UPDATE.md new file mode 100644 index 0000000..ba017f7 --- /dev/null +++ b/.governance/error/GOV-STANDARD-UPDATE.md @@ -0,0 +1,45 @@ +# GOV-STANDARD-UPDATE-001: explicit standard update could not complete + +## Situation + +The explicitly invoked compatibility updater found Goal unavailable or +incompatible, release verification failed, or Goal prepared or refused an +update. Its legacy `--pre-commit` protocol prepares changes and returns control +for review; the managed commit hook no longer invokes this updater. + +## Meaning + +The committed pin remains authoritative. A newer release gains trust only when +Goal verifies its annotated tag, final GitHub Release, full SHA and generated +digests. Preparation does not stage, commit, merge or publish the result. +The managed commit hook checks the staged local immutable pin and worktree +guard only. A new upstream release does not change a feature ticket's pin. + +## Safe resolution + +1. Preserve the worktree and read the Goal output above this code. +2. Install Goal with `governance adopt --latest --pre-commit` support. +3. Validate `.governance/standard-adoption.json`; when `executor` is + `koru-goal`, install a compatible Koru supervisor as well. +4. Allocate or resume exactly one standard-adoption ticket in its own worktree. +5. Run explicit adoption in that ticket, review the prepared diff, validate it + and stage it explicitly before committing. + +## Verification + +- The explicitly invoked Goal preparation command returns zero when the verified release + is already pinned. +- A prepared update remains visible for review and does not create a commit. +- An ordinary commit does not invoke Goal, Koru or release discovery. +- Managed governance and standard conformance pass after explicit restaging. + +## Do not + +- Do not use `--no-verify`, delete the hook or weaken digest checks. +- Do not trust `main`, `latest`, a lightweight tag or unbound release metadata. +- Do not commit the prepared update from an unrelated feature ticket. + +## Related rules + +- `P-CORE-007`, `P-CORE-014`, `P-CORE-024` +- `GOV-SYNC-001`, `GOV-TICKET-001` diff --git a/.governance/error/GOV-TICKET-001.md b/.governance/error/GOV-TICKET-001.md new file mode 100644 index 0000000..06ab4e8 --- /dev/null +++ b/.governance/error/GOV-TICKET-001.md @@ -0,0 +1,42 @@ +# GOV-TICKET-001: brak aktywnego właściciela implementacji + +## Situation + +Kod pojawia się, gdy diff implementacyjny nie ma dokładnie jednego ticketu ze +statusem `IN_PROGRESS`. Typowy przypadek to ustawienie `DONE / DONE` na branchu +PR przed trusted merge. + +## Meaning + +Zamknięty ticket nie udziela już uprawnienia do swojego `allowedPaths`. +Implementacyjny PR musi zachować `IN_PROGRESS / PUBLICATION` aż exact-head +review zostanie zintegrowany z gałęzią domyślną. + +## Safe resolution + +1. Sprawdź, czy PR nadal wskazuje oczekiwany HEAD i dokładnie jeden ticket. +2. Jeżeli implementacja nie została scalona, przywróć ticket do + `IN_PROGRESS / PUBLICATION` na tym samym branchu i ponów bramę. +3. Po trusted merge nie zmieniaj repozytorium. Chroniony kontroler zapisuje + terminalny receipt z PR head, merge SHA i post-merge checks oraz zwalnia + workstream. + +## Verification + +- Diff implementacyjnego PR zawiera aktywny ticket i przechodzi governance + gate. +- Zewnętrzny receipt wiąże merge SHA będący przodkiem bieżącego `main` i nie + tworzy commita ani PR-a. +- Zdalny branch implementacyjny znika dopiero po merge. + +## Do not + +- Nie osłabiaj `activeStatuses` i nie zezwalaj `DONE` na autoryzowanie diffu. +- Nie twórz closure commita, brancha ani PR-a. +- Nie traktuj lokalnego statusu Markdown jako trusted approval. + +## Related rules + +- `P-CORE-014`, `P-CORE-023` +- `C-TICKET-017` +- `C-PUBLISH-003`, `C-PUBLISH-009` diff --git a/.governance/error/GOV-TICKET-ACTIVITY.md b/.governance/error/GOV-TICKET-ACTIVITY.md new file mode 100644 index 0000000..4451d5f --- /dev/null +++ b/.governance/error/GOV-TICKET-ACTIVITY.md @@ -0,0 +1,53 @@ +# GOV-TICKET-ACTIVITY + +## Situation + +`GOV-TICKET-ACTIVITY-001` oznacza, że zarządzana polityka aktywności albo +klonowy rejestr terminalnych receiptów jest nieczytelny, niespójny z repozytorium +lub używa niewspieranego kontraktu. + +## Meaning + +Resolver nie ma dowodu, że historyczna projekcja aktywnego statusu utraciła +rezerwację. Pozostawia ją aktywną; nie uznaje tekstu, nazwy brancha ani wpisu w +cache za terminalne authority. + +## Safe resolution + +1. Zachowaj wadliwy plik rejestru jako dowód poza checkoutem i uruchom + `ticket_activity.py validate`, aby ustalić naruszony binding. +2. Odtwórz rejestr z chronionego źródła receiptów albo przenieś wadliwy cache w + odzyskiwalne miejsce. Brak opcjonalnego cache bezpiecznie wraca do projekcji + statusu. +3. Dodaj receipt przez `ticket_activity.py record --receipt `; komenda + zapisuje atomowo i odrzuca wpis, który nie zgadza się z lokalnym ancestry + ani z dokładnym dowodem patch-id chronionego rebase lub squash. +4. Gdy zdarzenie terminalne nie istnieje lub jego typ nie jest jeszcze + wspierany, skieruj pracę do `BLOCKED` lub `PLAN` przez autoryzowany lifecycle. + Taki stan zwalnia rezerwację bez fałszowania historii. + +## Verification + +- `ticket_activity.py validate` zwraca `status=valid`. +- `ticket_activity.py resolve` wskazuje `terminal-receipt` wyłącznie dla SHA + zintegrowanych z zadeklarowaną gałęzią docelową. Zwykły merge wymaga + ancestry; rebase wymaga istniejącego liniowego head, równolicznej liniowej + serii kończącej się terminalnym SHA i identycznych uporządkowanych patch-id. + Squash wymaga pojedynczego terminalnego commita, którego patch-id jest + identyczny z agregatem pełnego zakresu od wspólnej bazy do chronionego head. +- Allocator, governance gate i overlap checker zwracają ten sam wynik. + +## Do not + +- Nie zmieniaj historycznego README na `DONE` tylko po to, aby ominąć blokadę. +- Nie dodawaj wyjątku dla ticketu, brancha, repozytorium ani statusu w kodzie. +- Nie wyłączaj bramki i nie usuwaj unmerged branchy lub evidence jako remediacji. +- Nie traktuj mutable ref, URL, opisu PR ani samego istnienia receipt jako dowodu. +- Nie deklaruj metody merge w receipt jako zamiennika lokalnej weryfikacji Git. + +## Related rules + +- `P-TICKET-ACTIVITY-001` +- `P-RECOVERY-001` +- `C-TICKET-ACTIVITY-001` +- `C-RECOVERY-001` diff --git a/.governance/error/GOV-TICKET-ALLOCATION.md b/.governance/error/GOV-TICKET-ALLOCATION.md new file mode 100644 index 0000000..56b096a --- /dev/null +++ b/.governance/error/GOV-TICKET-ALLOCATION.md @@ -0,0 +1,78 @@ +# GOV-TICKET-ALLOCATION i GOV-TICKET-LOCK + +## Situation + +Runbook obejmuje `GOV-TICKET-ALLOCATION-001`–`004` oraz +`GOV-TICKET-LOCK-001`–`004`. Kody oznaczają +aktywny lub uszkodzony lock, nieczytelny high-water, istniejący katalog, +nieodświeżone zdalne refy, ticket poza rezerwacją albo dwa różne intenty z tym +samym numerem. Kod `003` oznacza brak świeżego receipt z zarejestrowanego +allocatora, a `004` — że receipt wskazuje tożsamość już widoczną w repozytorium. + +## Meaning + +Numer ticketu jest zasobem całej zadeklarowanej domeny writerów. Dla linked +worktree jednego klonu `project/new-ticket.sh` odświeża refy, zdobywa wspólny +lock i podnosi high-water. Niezależne klony lub node nie współdzielą tego stanu, +więc muszą używać profilu `registered`: jeden atomowy proces przydziela numer i +wydaje krótko żyjący receipt związany z request digest oraz fencing tokenem. +Ręczne `mkdir`, skopiowanie katalogu albo sam `fetch` nie tworzą rezerwacji. + +## Safe resolution + +1. Zatrzymaj nowe skutki przegrywającego writera i zachowaj dokładny HEAD, + lease, PR oraz lossless material delta. +2. Wybierz kanoniczną historię wyłącznie z chronionego terminalnego merge + receiptu; nazwa brancha, czas lokalny i status Markdown nie rozstrzygają. +3. Dla braku receipt wyślij kanoniczny request emitowany przez + `new-ticket.sh` do skonfigurowanego `processUri`, a następnie ponów dokładne + wywołanie z otrzymanym receiptem. +4. Przy kolizji odwołaj superseded lease, zaalokuj successor przez registered + process i odtwórz na aktualnym target wyłącznie zachowany material delta. +5. Zweryfikuj równoważność diffu i wszystkie bramki, utwórz successor PR, a + dopiero potem zamknij predecessor jako `superseded` i uruchom Validator dla + exact head. +6. W profilu lokalnym napraw łączność z `origin` i ponów allocator; stale lock + usuwaj tylko po potwierdzeniu braku procesu. +7. Gdy zarezerwowany ticket (numer nie większy od high-water, rekord w + `.subactor/leases/`) stracił kanoniczny checkout, branch lub + niezacommitowane metadane, nie alokuj nowego numeru i nie odtwarzaj ich + ręcznie. Uruchom `new-ticket.sh --workstream W --restore-request FILE + --recovery-lease-store DIR`. Request + `new-project.ticket-restoration-request/v1` wiąże ticket, `worktreeId`, + dokładny HEAD (`null` oznacza bazę rezerwacji), snapshot + `new-project.ticket-metadata-snapshot/v1` z jego SHA-256 oraz CAS żywego + leasu kontrolera w fazie `claimed` lub `editing`. Restoration tworzy tylko + brakujący branch, względną rejestrację checkoutu oraz `README.md` i + `intent.json`; ponowienie po przerwaniu kończy brakujące kroki, a gotowy + stan zwraca `ALREADY_RESTORED`. Nie daje prawa do edycji ani merge: dalej + obowiązuje `work_start_check.py --ticket`. + +## Verification + +- `git worktree list --porcelain` pokazuje każdy sklasyfikowany checkout. +- Wspólny high-water jest nie mniejszy od najwyższego niescalonego claimu. +- Każdy numer ma jedną tożsamość `ticket + summary + workstream`. +- W trybie rozproszonym receipt wiąże repository, request digest, issuer, + process URI, ticket, fencing token i niewygasły lease. +- Predecessor nie jest zamknięty przed utworzeniem i weryfikacją successora. +- Ponowne uruchomienie workspace checkera nie emituje kodów allocation. + +## Do not + +- Nie zmieniaj numeru przez ręczne `mv` i nie kopiuj historii obu branchy. +- Nie usuwaj dirty/unreachable worktree ani locka bez identyfikacji procesu. +- Nie przydzielaj numeru offline ze starych refów. +- Nie przejmuj cudzej rezerwacji po wygaśnięciu leasu; zmianę właściciela + wykonuje kontroler, a restoration jej wymaga. +- Nie traktuj lokalnego high-water, receipt ani samego ERROR jako merge lub + execution authority. +- Nie zamykaj przegrywającego PR przed zachowaniem delta i utworzeniem + successora. + +## Related rules + +- `P-CORE-022` +- `C-CONCURRENCY-001`, `C-CONCURRENCY-002`, `C-CONCURRENCY-003` +- `C-CONCURRENCY-004`, `P-TICKET-ALLOCATION-001` +- `P-WORKSPACE-001`, `C-WORKSPACE-001` diff --git a/.governance/error/GOV-VERSION.md b/.governance/error/GOV-VERSION.md new file mode 100644 index 0000000..4b5e7cf --- /dev/null +++ b/.governance/error/GOV-VERSION.md @@ -0,0 +1,54 @@ +# GOV-VERSION: rozjazd wersji między VERSION a deklaracjami w pakiecie + +Kody: `GOV-VERSION-001` (emitowany przez `scripts/governance_check.py`). + +## Situation + +W repozytorium znajduje się plik `VERSION`, ale deklaracja wersji w manifestach pakietów (`pyproject.toml`, `setup.py`, `package.json`, `Cargo.toml`) lub statyczne `__version__` w kodzie źródłowym (`__init__.py`) różni się od wartości w `VERSION`. + +## Meaning + +Plik `VERSION` jest jedynym autorytatywnym źródłem prawdy (SSOT) dla wersji repozytorium i jego pakietu. Rozbieżność prowadzi do publikacji pakietów o niewłaściwych numerach na rejestrach (PyPI, npm, crates.io), błędu regresji (`Local version state regresses behind released evidence`) oraz fałszywych stanów synchronizacji. + +Ponadto w modelach podziału na workstreamy (`manifest.json`), workstream `integration` posiada `VERSION` i manifesty pakietów, podczas gdy `application` posiada kod źródłowy. Zahardkodowanie `__version__ = "X.Y.Z"` w kodzie źródłowym uniemożliwia podbicie wersji przez `integration` bez naruszenia izolacji ścieżek (`GOV-WORKSTREAM-003`). + +## Safe resolution + +1. Upewnij się, że `VERSION` zawiera docelowy numer wersji (np. `2.2.51`). +2. W manifestach pakietów zaktualizuj wersję do zgodnej z `VERSION`: + - `pyproject.toml`: `version = "2.2.51"` w `[project]` lub sekcji narzędzia; + - `setup.py`: `version = "2.2.51"` (lub dynamiczny odczyt z `VERSION`); + - `package.json`: `"version": "2.2.51"`; + - `Cargo.toml`: `version = "2.2.51"` w `[package]`. +3. W kodzie źródłowym Pythona (`__init__.py`) preferuj dynamiczny odczyt wersji: + ```python + try: + from importlib.metadata import version as _meta_version + __version__ = _meta_version("my-package") + except Exception: + from pathlib import Path + _root = Path(__file__).resolve().parent + while _root.parent != _root and not (_root / "VERSION").is_file(): + _root = _root.parent + _version_file = _root / "VERSION" + __version__ = _version_file.read_text(encoding="utf-8").strip() if _version_file.is_file() else "0.0.0" + ``` + Jeśli w kodzie pozostaje statyczne przypisanie `__version__ = "..."`, jego wartość musi być identyczna z `VERSION`. +4. Uruchom bramkę: `./project/governance-check.sh`. + +## Verification + +- `governance_check.py` zwraca sukces i brak naruszeń `GOV-VERSION-001`. +- Narzędzia budowania (`python -m build`, `uv build`, `npm pack`) generują artefakty o numerze zgodnym z `VERSION`. +- `goal check-versions` potwierdza spójność deklaracji lokalnych. + +## Do not + +- Nie hardkoduj różnych wersji w różnych manifestach w tym samym repozytorium. +- Nie pozostawiaj przestarzałego `setup.py` podczas migracji do `pyproject.toml`. +- Nie pomijaj synchronizacji wersji przy przygotowywaniu wydań. + +## Related rules + +- `P-CORE-027` +- `AGENTS.md` rule 23 diff --git a/.governance/error/GOV-WORK-CONTINUITY.md b/.governance/error/GOV-WORK-CONTINUITY.md new file mode 100644 index 0000000..f9bcede --- /dev/null +++ b/.governance/error/GOV-WORK-CONTINUITY.md @@ -0,0 +1,67 @@ +# GOV-WORK-CONTINUITY + +## Situation + +- `GOV-CONTINUITY-001` oznacza niepoprawny checkpoint albo próbę handoffu + brudnego workspace bez autoryzowanego commita lub bezpiecznego snapshotu. +- `GOV-CONTINUITY-002` oznacza przerwany, cofnięty lub ponownie związany chain + receiptów. +- `GOV-CONTINUITY-003` oznacza, że bieżący Git, intent albo workspace nie + odpowiada zapisowi, od którego agent próbuje wznowić pracę. + +## Meaning + +Nie można dowieść, że materialna delta i zakres zadania są odtwarzalne bez +pamięci rozmowy. Checkpoint jest projekcją nawigacyjną, więc rozjazd nie +uprawnia go do nadpisania bieżącego filesystemu, odtworzenia snapshotu ani +użycia zapisanego fencing tokenu. + +## Safe resolution + +1. Zatrzymaj efekty repozytorium i zewnętrzne; zachowaj bieżący worktree bez + resetu, cleanowania i automatycznego stasha. +2. Odczytaj bieżący branch/HEAD, `git status`, intent, PR, Validator run, + terminalne receipt'y i authority lease z ich źródeł prawdy. +3. Uruchom `work_continuity.py validate` dla checkpointu oraz całego rejestru. + Dla `002` odtwórz pełny chain z chronionego receipt store; nie dopisuj + sztucznego poprzednika. +4. Uruchom `work_continuity.py verify --root . --ticket ticket-NNN`. +5. Dla `001` zapisz materialną deltę w autoryzowanym commicie albo zleć + chronionemu kontrolerowi content-addressed snapshot po secret scan. Jeżeli + żadna droga nie jest dozwolona, ustaw ticket na `BLOCKED` i zachowaj dane. +6. Dla `003` przejdź do `reconcile`: porównaj obie delty, wybierz authority z + aktualnego intentu i receiptów, a przed dalszym zapisem pozyskaj aktualny + lease/fencing token oraz uruchom governance gate. + +## Verification + +```bash +python3 .governance/work_continuity.py validate +python3 .governance/work_continuity.py verify --root . --ticket ticket-NNN +./project/governance-check.sh --actor agent +``` + +Oczekiwany wynik `verify` to `matches-observed-state` wraz z +`authorityVerified=false`. Kontroler osobno potwierdza aktualne authority i +lease przed efektem. + +## Do not + +- Nie traktuj streszczenia rozmowy, ticket prose, TODO ani raw logu jako kopii + danych roboczych. +- Nie używaj `git reset`, `git clean`, force push, automatycznego restore ani + usuwania worktree do usunięcia rozjazdu. +- Nie kopiuj sekretu, pełnego diffu, review body lub absolutnej ścieżki hosta do + checkpointu, logu lub receiptu. +- Nie uznawaj lokalnego rejestru `.git` za ochronę przed utratą dysku i nie + deklaruj cross-machine durability bez zewnętrznego receipt store. +- Nie odnawiaj sesji ani lease wyłącznie na podstawie checkpointu. + +## Related rules + +- `P-CONTINUITY-001` +- `P-CONTINUITY-002` +- `P-CONTINUITY-003` +- `C-CONTINUITY-001` +- `C-CONTINUITY-002` +- `C-CONTINUITY-003` diff --git a/.governance/error/GOV-WORK-START.md b/.governance/error/GOV-WORK-START.md new file mode 100644 index 0000000..fe5bbaf --- /dev/null +++ b/.governance/error/GOV-WORK-START.md @@ -0,0 +1,233 @@ +# GOV-WORK-START-001 — work admission before allocation + +## Situation + +A new task would overlap pending work, exceed the workstream limit, or start +from incomplete branch/worktree observations. An unbound branch rejected by +the commit hook is not necessarily a Git merge conflict. + +## Meaning + +The query reads registered worktrees, local branch contributions, dirty paths, +branch-owned intent and the managed activity resolver. It does not authorize a +writer, transfer a lease, refresh remotes, allocate a ticket or close old work. +The complete report is clone-local and may contain private filesystem paths; +keep it in private receipt storage, not a tracked ticket. + +## Safe resolution + +| Route | Use | Boundary | +| --- | --- | --- | +| REUSE_EXISTING | Continue the matching canonical ticket checkout. | Revalidate intent, owner and current lease first. | +| ASSIST_READ_ONLY | Help an active delivery with analysis or review. | No second writer or trusted self-approval. | +| HANDOFF_REQUIRED | Reconcile pending work from an inactive ticket. | Accepted scope, snapshot and controller CAS; automatic takeover only through `GOV-IDLE-TAKEOVER` after the configured idle time. | +| SERIALIZE | Workstream capacity is occupied. | Queue without another delivery worktree. | +| RECONCILE | Owner, ancestry, pending branch or observations are uncertain. | Preserve work and resolve the specific missing evidence. | +| NEW_TICKET_CANDIDATE | Scope and WIP capacity permit new allocation. | Planning candidate only; never write authority. | + +```text +task -> registered clone observation + |-> existing work -> reuse / assist / handoff / queue + |-> uncertainty -> reconcile; preserve data + `-> free scope -> managed allocation candidate + -> intent + owner + fencing + gate -> one writer +``` + +1. Run `python3 scripts/work_start_check.py --root . --workstream ` + (adopters use `.governance/work_start_check.py`). Add `--ticket ticket-NNN` + for an explicit continuation; optionally narrow with repeatable `--path`. +2. Follow the route: REUSE_EXISTING, ASSIST_READ_ONLY, HANDOFF_REQUIRED, + SERIALIZE, RECONCILE or NEW_TICKET_CANDIDATE. Finish existing authorized + work first. Read-only assistance is not permission to edit another writer's + files or self-approve their PR. +3. Handoff requires an accepted scope and controller-owned compare-and-swap + lease transfer/reacquisition, a restorable snapshot and exact-head checks. + If unavailable, queue the affected work without a delivery worktree. +4. Keep disjoint authorized work moving. Do not count a clean integrated + historical checkout as a new pending delivery merely because it exists. +5. Reobserve immediately before allocation and before writing; verify intent, + owner, fencing and the governance gate at the effect boundary. A saved + report is evidence, never a replayable admission token. + +For a branch without a registered worktree, distinct commit IDs alone are not +a competing delta. Admission compares the complete Git tree of **every** +commit unique to that branch with target trees strictly after the common +ancestor. A new intentional rollback cannot reuse a pre-divergence snapshot. +If all snapshots already occur there, the branch remains in `uncheckedBranches` +but does not block admission. This narrowly handles preserved pre-rewrite +copies without renaming or deleting them. Matching HEAD alone, matching paths, +or matching patch IDs is insufficient. An unmatched intermediate commit or +later new work still routes to reconciliation. Missing history fails closed. + +### Stale carrier of an integrated ticket + +Blocker `integrated-ticket-carrier` names an active-projected ticket whose +carrier is dirty in a checkout while its directory is already on the observed +target and no `ticket/NNN` branch lies outside that target. A typical source is +an allocation-time copy left in a primary checkout that is behind the target. +The conservative `status-projection` activity is unchanged, so the copy still +counts toward the workstream limit; admission routes to RECONCILE instead of an +unexplained SERIALIZE. Resolve it without discarding unknown work: + +1. Compare each dirty carrier with the target version and confirm no process or + session is still editing it. +2. Store a content-addressed, secret-scanned snapshot of every dirty file in + ignored receipt storage, recording base HEAD and target SHA. +3. Recheck the file digests, restore only the snapshotted carrier paths and + fast-forward the checkout; leave unrelated dirty work in place. +4. Reobserve admission. A differing carrier that records real continuation work + needs a new or reused ticket, not a restore. + +### Writes in the selected checkout + +The selected checkout of REUSE_EXISTING is not a competing peer, yet another +writer may have left uncommitted changes in it. Every registered checkout +reports `dirtyNewestModifiedAt`, the newest modification time of its dirty +paths: recency evidence only, never writer identity or authority. When dirty +paths of the selected checkout overlap the requested scope, `requiredBeforeWrite` +asks the caller to confirm they belong to this session. A caller that observed +the checkout earlier passes that report's `dirtyDigest` with +`--ticket ticket-NNN --expect-dirty-digest `; any change since then adds +blocker `selected-checkout-changed` and removes REUSE_EXISTING. This is a +clone-local compare-and-swap on content, not a lease or cross-clone lock. +Disjoint dirty work of another writer may continue beside authorized work. + +Registered checkout observations, dirty paths, active scopes and WIP limits +are unchanged. Historical content inclusion is not current behavior, owner +consent, a merge receipt, ticket closure or permission to discard history. +Use the normal reconciliation process for cleanup. Target-tree indexing is +local to one observation; a changed target cannot reuse an earlier result. + +### Optional publication observation + +Add `--observe-publication` to the same query to read live `origin` branch +advertisements, without fetch, ref updates, staging or lazy object downloads. +For example, from an adopted checkout: + +```bash +python3 .governance/work_start_check.py --root . --workstream integration \ + --ticket ticket-001 --observe-publication +``` + +Use the actual declared workstream and ticket. Without this flag the query +remains local and its admission behavior is unchanged. The optional field is +`new-project.publication-observation/v1`, addressed by +`urn:wellmanifest:new-project:schema:work-start-report:v1#publicationObservation`. +Use the helper and schema from the same immutable pin; an older closed schema +does not accept the new opt-in field. This is an observation, not a new gate. + +| Field per registered checkout | Meaning | +| --- | --- | +| `uncommittedPathCount` | Staged, unstaged and untracked paths, including tracking carriers. | +| `unpublishedCommitCount` | Commits reachable from HEAD but not from any observed `origin` branch; `null` when not proven. | +| `remoteContainingRefs` | Advertised branch refs proven to contain the complete HEAD history. | +| `sameBranchContainsHead` | Whether the remote branch with the same name contains HEAD; separate from publication on another branch. | +| `headReachableFromTarget` | Git ancestry only, never protected merge, review or release evidence. | +| `nextAction` | Read-only recommendation, not effect authorization. | + +Scope is explicitly `origin-heads`: other remotes, tags and hidden PR refs are +not queried. Being ahead of local `main` or a same-name upstream is not proof +that code is absent from GitHub. Shallow history or missing advertised objects +produce `partial`; exact HEAD/ancestry evidence can still prove publication, +but incomplete history cannot prove a nonzero unpublished count. Unavailable +or malformed remote data produces `unavailable`; a changed second advertisement +produces `changed` and invalidates remote-derived facts. `null` is not zero. +No prompt for credentials or Git stderr is exposed in the report. This is a +bounded observation, not an atomic remote snapshot or a cross-machine lock. + +The result explicitly lists PR, checks, approval, protected merge, release and +deployment as unobserved stages. Preserve dirty work regardless of remote +status. Gather those stages' own exact-head receipts before claiming DONE. + +### Optional controller availability + +`--controller-store DIR --controller-repository OWNER/NAME` adds a read-only +`editingResource` observation (`new-project.editing-resource-observation/v1`) +of the controller's exclusive editing resource for that repository and target +branch. Run it before preparing an expiring editing credential or admission. +The route still reports planning eligibility only: disjoint paths can select +`NEW_TICKET_CANDIDATE` or `REUSE_EXISTING` while the resource is +`HELD_BY_OTHER`. `HELD_EXPIRED` still blocks: expiry alone never authorizes a +takeover; the controller must release it, record a protected handoff, or the +ticket must qualify for `GOV-IDLE-TAKEOVER` (no observed activity for +`coordination.idleTakeover.idleMinutes`). +`UNAVAILABLE` carries a reason (`controller-store-missing`, +`controller-state-unreadable`, `controller-state-invalid`, +`controller-state-changed`; runtime clients may report transport, pin or +policy failures) and never implies availability. The holder is a safe +reference (lease, ticket, phase, expiry) without owner session or plan. +`queueSupported` is `false` for the file controller, which has no queue; a +runtime that queues reports `QUEUED`, and a queued request is not a grant. +Follow `nextAction`, bind `stateDigest` and `observedAt` as the observation's +freshness, and re-observe instead of retrying an admission whose window +elapsed. + +## Verification + +Report `new-project.work-start-report/v1` uses closed schema +`urn:wellmanifest:new-project:schema:work-start-report:v1`. It binds refs, +intent and dirty-content digests, including changes to already dirty files. +The helper, schema and this runbook ship through the immutable package. +Files and opted-in SQLite ticket input use the managed activity resolver. + +`python3 tests/work_start_test.py` checks real Git fixtures and no-write queries. +The managed allocator invokes `--allocation-check` under its clone-wide ID +lock before reserving a number. A rejected attempt leaves no new ticket, +high-water reservation or worktree. The query exit code alone does not +authorize development; REUSE_EXISTING also requires the current writer lease. + +The allocator accepts repeatable `--path` arguments for explicit implementation +scope, for example `./project/new-ticket.sh --workstream api --path 'api/new/**'`. +Quote glob patterns: the shell must not expand them. The managed storage bridge +validates repository-relative paths against the gate's workstream ownership +predicate before reservation. Malformed, unowned and tracking-only scopes fail. +The exact arguments reach live admission under the allocation lock; the admitted +paths are retained in file and SQLite intents. Without `--path`, admission still +uses the whole workstream. A disjoint scope does not bypass an occupied WIP slot. +Revalidate admission and fencing if the eventual intent expands beyond this scope. + +This is not a global scheduler or an editor lock. Independent clones, live +GitHub PR/check/release state, processes and writer authority require separate observations. +The query does not fetch or verify a lease. Recheck it at the effect boundary; +the allocator's ID lock does not replace writer fencing. Unborn seed bootstrap +retains its separate contract, not a development-gate exemption. + +## Do not + +- Do not use `--force-new`, rename a branch or disable hooks to bypass admission. +- Do not merge, reset, clean, delete, stage or copy foreign work automatically. +- Do not guess owners, remote freshness or independent-clone state from Git. +- Do not treat BLOCKED/PLAN as permission to take a dirty checkout. +- Do not repeat allocation to resolve a missing observation. + +## Related rules + +P-WORKSPACE-005, P-WORKSPACE-006, C-START-004, C-CONCURRENCY-005, +P-CORE-014, P-TICKET-ACTIVITY-001 and P-LEASE-001. + +## Ticket writer fencing + +Allocation requires `--writer-actor` and `--writer-session`, or the explicit +`NEW_PROJECT_WRITER_ACTOR` and `NEW_PROJECT_WRITER_SESSION` environment values. +The agent provider (`--agent`) is display metadata, not a session identity. +The allocator keeps scaffolds in its canonical linked worktree and records the +caller in the existing lease fields. Invalid identity fails before reservation. + +The launcher supplies these same identity values plus `NEW_PROJECT_LEASE_REVISION` +and `NEW_PROJECT_FENCING_TOKEN` from current controller evidence to Git hooks. +`change_lease_check.py writer-check --root . --operation commit --ref +refs/heads/ticket/NNN-slug` performs a read-only check. Push checks resolve each +source and destination ticket ref through Git's registered primary and worktrees, +including when the push originates from another checkout. Missing or unsafe +leases/runtime produce GOV-CHANGE-LEASE-001; identity, expired lease or stale CAS +produce GOV-CHANGE-LEASE-002; released/frozen phases produce GOV-CHANGE-LEASE-003. +The clone-local repository hash matches the allocator's existing lease binding. + +A successor must obtain an independently accepted handoff through the controller: +observe the old owner gone or explicitly handed off, re-observe dirty work and +HEAD, and have the controller publish the successor lease with fresh monotonic +CAS values. Pass that evidence to the successor session. Hooks never consume a +local handoff file, update ownership, renew a timeout, or infer caller identity +from lease contents. Do not copy another session's identity or fencing values. +These local checks do not defend against an OS user who can alter hooks or +impersonate launcher environment, and do not replace protected publication. diff --git a/.governance/error/GOV-WORKSPACE-LIFECYCLE.md b/.governance/error/GOV-WORKSPACE-LIFECYCLE.md new file mode 100644 index 0000000..eefc147 --- /dev/null +++ b/.governance/error/GOV-WORKSPACE-LIFECYCLE.md @@ -0,0 +1,102 @@ +# GOV-WORKSPACE-LIFECYCLE + +## Situation + +Kody `GOV-WORKSPACE-LIFECYCLE-001`–`004` oznaczają pozostały linked worktree, +duplikat klonu, audyt, którego nie da się bezpiecznie zakończyć, albo +non-defaultowy lokalny branch pozostawiony w `refs/heads`. + +Zdalny audyt ma osobne, niezamienne kody: + +| Kod | Obserwacja | Pierwszy bezpieczny krok | +| --- | --- | --- | +| `GOV-BRANCH-LIFECYCLE-001` | wyłączone usuwanie brancha po merge | sprawdzić chronioną konfigurację repozytorium | +| `GOV-BRANCH-LIFECYCLE-002` | branch bez otwartego PR | odczytać dokładny HEAD, historię PR i pozostały intent | +| `GOV-BRANCH-LIFECYCLE-003` | brak, błąd formatu lub niespójność snapshotu | ponowić obserwację, bez zmiany branchy | + +## Meaning + +Stan terminalny wymaga jednego podstawowego checkoutu, lecz żaden checker nie +ma prawa automatycznie niszczyć nieznanych danych. Lokalny filesystem i zdalny +GitHub są osobnymi granicami dowodu. + +Snapshot branch lifecycle v1 nie zawiera SHA branchy, zamkniętych PR, +aktywnych writerów ani decyzji właściciela. `002` nie dowodzi porzucenia pracy, +konfliktu zapisu ani możliwości bezpiecznego usunięcia. `003` nie jest poleceniem +cleanup. Kod wypchnięty na branch nie jest jeszcze scalony, wydany ani wdrożony; +sam push/draft PR nie uruchamia terminalnego cleanup. + +## Safe resolution + +### Najpierw skutek i klasyfikacja + +1. Ustal, czy zlecono zachowanie postępu, push, merge, release, deploy czy + terminalny cleanup. Odczytaj aktualne lokalne/zdalne SHA, dirty state i PR. + Nie ponawiaj push po timeout, zanim sprawdzisz, czy zdalny ref już wskazuje + oczekiwany commit. Obserwacja identycznego SHA nie dowodzi merge lub wydania. +2. Dla `GOV-BRANCH-LIFECYCLE-001` właściwy operator/kontroler ustawia + `delete_branch_on_merge=true` w granicach istniejącej autoryzacji. Odczyt + ustawienia potwierdza efekt; nie usuwa się przy tym niescalonych branchy. +3. Dla `GOV-BRANCH-LIFECYCLE-002` odczytaj również zamknięte PR i ewentualny + PR następcy. Zachowaj oryginalny HEAD oraz bezpieczny snapshot niezapisanych + zmian. Kontynuuj istniejący ticket/PR, jeśli odpowiada autoryzowanej pracy. + Przy zastąpieniu starego brancha uzgodnij wszystkie kryteria intentu przez + zarządzany `branch_intent_reconciliation.py`. Nie twórz pustego PR ani + duplikatu zadania dla samego zaspokojenia bramki. Usunięcie wymaga osobno + zweryfikowanej dyspozycji i ponownego odczytu dokładnego refa przed skutkiem. +4. Dla `GOV-BRANCH-LIFECYCLE-003` uruchom ponowny odczyt z chronionego + kolektora. Lista branchy i PR może zmienić się między wywołaniami API; + zweryfikuj wskazane rozbieżne refy. Nie „naprawiaj” JSON przez usunięcie + wpisu i nie usuwaj zdalnego brancha, aby dopasować go do starego snapshotu. +5. Przy tej samej odmowie i niezmienionych wejściach zapisz jeden oczekujący + krok w istniejącym journalu/tickecie i nazwij konkretny brak. Wznów próbę + po zmianie istotnego wejścia lub zgodnie z ograniczonym retry dla awarii + przejściowej. Nie resetuj licznika przez nowy prompt, ticket lub worktree. + Kontynuuj niezależną autoryzowaną pracę. Ta recepta nie zamienia FAIL w PASS. + +### Cleanup dopiero po klasyfikacji + +1. Dla każdego checkoutu zapisz dirty state, branch, HEAD i tożsamość remote. +2. Potwierdź, że HEAD jest zintegrowany albo że właściciel jawnie porzucił + unmerged pilot. Zweryfikuj wymagany receipt terminalny, zwolnienie lease + i brak aktywnego procesu korzystającego z checkoutu. Historia wspólna z innym + branchem nie oznacza sama w sobie konkurującego writera ani prawa usunięcia. +3. Linked worktree usuń przez `git worktree remove `, potem + `git worktree prune` i dopiero wtedy usuń zwolniony lokalny branch. +4. Zweryfikowany duplikat klonu przenieś do odzyskiwalnego kosza. +5. Dla kodu `004` sprawdź wskazane `branch`, `head`, `defaultBranch`, `checkout` + i `primary`. Jeśli commit nie jest zintegrowany, zachowaj go pod opisanym, + zdalnie zweryfikowanym tagiem/refem albo uzyskaj jawną decyzję właściciela. + Dopiero po zwolnieniu worktree usuń dokładny lokalny ref. W czasie aktywnej + pracy można zwolnić branch z findingu wyłącznie przez dokładną ścieżkę + checkoutu przekazaną jako `--allow`; wzorce i sama nazwa brancha nie są + wyjątkiem. + +## Verification + +- Lokalny workspace checker kończy się `GOV-WORKSPACE-PASS` bez + nieallowlistowanych checkoutów. +- Osobny workflow GitHub potwierdza zdalne branche i ich powiązanie z PR oraz + `delete_branch_on_merge=true`; nie potwierdza lokalnego filesystemu. Oczekiwanie + „tylko main” dotyczy zakończonego porządkowania, nie aktywnej dostawy. +- Każdy usunięty ref/checkout ma dokładny, zweryfikowany cel i dowód dopuszczalności. +- Status podaje osobno: commit, push, PR, testy, merge, release i deploy. Brak + publikacji w rejestrze nie jest zastępowany wersją wypisaną przez lokalny runtime. + +## Do not + +- Nie używaj globów rekurencyjnych ani nie usuwaj primary worktree. +- Nie uznawaj zielonego CI za dowód stanu lokalnego dysku. +- Nie usuwaj danych dirty lub unreachable bez decyzji właściciela. +- Nie traktuj `GOV-WORKSPACE-PASS` jako uprawnienia do usuwania refów; checker + jest wyłącznie read-only. +- Nie utożsamiaj pustej listy otwartych PR z dowodem, że wszystkie prace scalono. +- Nie wyłączaj sekretów, scope, lease, hooka ani niezależnego review w celu + skrócenia publikacji. Wadliwą diagnostykę popraw z testem regresji u jej źródła. + +## Related rules + +- `P-WORKSPACE-001`–`004` +- `C-WORKSPACE-001`–`004` +- `P-BRANCH-001`–`003` +- `P-RECOVERY-001`, `C-RECOVERY-001`, `P-BLOCK-005` diff --git a/.governance/error/GOV-WORKTREE-OVERLAP.md b/.governance/error/GOV-WORKTREE-OVERLAP.md new file mode 100644 index 0000000..953aae4 --- /dev/null +++ b/.governance/error/GOV-WORKTREE-OVERLAP.md @@ -0,0 +1,82 @@ +# GOV-WORKTREE-OVERLAP + +## Situation + +Kody `GOV-WORKTREE-OVERLAP-001`–`003` oznaczają, że dwa lub więcej checkoutów +tej samej tożsamości repozytorium jednocześnie zmienia te same ścieżki, albo +że ich aktywne `allowedPaths` nachodzą się bez `conflictsWith`. + +To nie jest ten sam finding co `GOV-WORKSPACE-LIFECYCLE-*`. Tamten kod jest +terminalny (pozostały worktree po merge). Ten kod jest **proaktywny**: +równoległe worktree są dozwolone, nachodzące zmiany nie. + +## Meaning + +`001` — rzeczywisty konkurencyjny wkład: brudne zmiany wobec brudnych zmian +lub nowych commitów drugiej strony. Gdy oba checkouty widzą ten sam SHA +`origin/`, wkład każdego jest liczony od jego wspólnego przodka z +tym SHA. Zmiany odziedziczone z main nie należą do nowego writera. +Dla rozbieżnych commitów sprawdzany jest merge; konflikt przypisuje się parze +tylko na ścieżce, na której obie strony wnoszą wkład. Konflikt starej gałęzi +z main pozostaje widoczny w jej stanie, ale nie blokuje niezależnego writera. +Brak zgodnych refów albo odczytu obiektów zachowuje konserwatywne sprawdzanie +od wspólnego przodka pary. Tak samo traktowane są historie ze zmianą nazwy: +Git może zgłosić konflikt pod inną ścieżką niż pierwotna edycja. Checker nie +pobiera refów i nie zatwierdza merge'a. +`002` — dwa `IN_PROGRESS` intent.json w różnych worktree deklarują nachodzące +`allowedPaths` i żadne nie wymienia drugiego w `conflictsWith`. +`003` — audyt nie dał się bezpiecznie dokończyć. + +Zakres zgłoszenia jest w polu `scope` raportu. Bramka repozytorium +(`--identity-of` / `--scope repository`, domyślne w pre-commit) odpowiada tylko +za własną tożsamość repozytorium — konflikt w cudzym repo nie blokuje tu +commita. Skan workspace (timer, path unit) raportuje wszystko, co znajdzie. + +`TODO.md`, `project/TICKETS.md` i `project/ticket-*/**` są ignorowane; każdy +intent je deklaruje, więc porównywanie ich dawałoby overlap dla każdej pary. +Ticket liczy się tylko w tym worktree, którego **branch** jest jego branchem — +scalona kopia katalogu ticketu w innym worktree nie jest drugim pisarzem. + +## Safe resolution + +Najpierw porównaj dokładne HEAD-y, wspólnego przodka i rzeczywiste brudne +ścieżki. Snapshot tego samego HEAD-a z samymi plikami śledzenia ticketu nie +jest drugim writerem kodu. Zachowaj go; sama kwarantanna nie wymaga usunięcia +ani nowego pytania do użytkownika. Błąd klasyfikacji napraw w standardzie z +testem regresji i adoptuj zweryfikowany pakiet, bez obchodzenia hooka. +Procedura poniżej dotyczy potwierdzonej konkurencyjnej zmiany. Zasady decyzji: +`docs/AGENT_DECISIONS.md` w HOME, a u adoptera +`.governance/AGENT_DECISIONS.md`. + +1. Zatrzymaj jednego writera albo przenieś nachodzące ścieżki do jednego + ticketu / workstreamu integracyjnego. +2. Dopisz `conflictsWith` po obu stronach i zostaw tylko jeden ticket + `IN_PROGRESS` na ten zakres. +3. Nie merguj, dopóki overlap nie zniknie albo nie zostanie zserializowany. +4. Po zintegrowaniu pierwszego ticketu odpal guard ponownie na drugim. + +## Verification + +```bash +python3 scripts/worktree_overlap_check.py --workspace-root . --format text +# albo po adopcji: +python3 .governance/worktree_guard.py --root . --once +# skan całego workspace na timerze: +systemctl --user start worktree-guard@$(systemd-escape --path ~/github/subactor).service +cat ~/.local/state/worktree-guard/$(systemd-escape --path ~/github/subactor).json +``` + +Oczekiwany wynik: `GOV-WORKTREE-OVERLAP-PASS`. + +## Do not + +- Nie traktuj samego faktu „jest więcej niż jeden worktree” jako błędu. +- Nie usuwaj cudzego worktree automatycznie. +- Nie omijaj guarda przez `--allow` wzorcem; ten checker nie ma allowlisty + na nachodzące ścieżki. + +## Related rules + +- `git-lifecycle` `local-commit` / `integrate` wymagają braku niezgłoszonego overlapu. +- `ticket-lifecycle` wymaga `conflictsWith` przy nachodzącym zakresie. +- `P-CORE` / `C-TICKET` rule 11 (parallel work) w `AGENTS.md`. diff --git a/.governance/error/README.md b/.governance/error/README.md new file mode 100644 index 0000000..40cf8e9 --- /dev/null +++ b/.governance/error/README.md @@ -0,0 +1,24 @@ +# Kanoniczne rozwiązania diagnostyk + +Katalog `error/` zawiera runbooki dla stabilnych kodów `GOV-*`, których +rozwiązanie jest wieloetapowe, wymaga klasyfikacji danych albo może prowadzić +do destrukcyjnej operacji. Krótka, maszynowa remediacja zawsze pozostaje w +`governance/diagnostics.json`; pole `documentation` wskazuje ten katalog. + +Runbook nie jest wyjątkiem od polityki. W razie konfliktu obowiązuje kolejno +`POLICY.md`, `CONTRIBUTING.md`, finding z bieżącego uruchomienia i dopiero +procedura pomocnicza. Historyczne pliki ticketów wyjaśniają, dlaczego standard +się zmienił, ale nie są instrukcją operacyjną dla kolejnych zdarzeń. + +Każdy podlinkowany runbook musi zawierać dokładnie rozpoznawalne sekcje: + +- `Situation` — kiedy kod występuje; +- `Meaning` — który invariant został naruszony; +- `Safe resolution` — niedestrukcyjne kroki naprawy; +- `Verification` — deterministyczne sprawdzenie wyniku; +- `Do not` — zabronione skróty i ryzyka; +- `Related rules` — stabilne identyfikatory reguł. + +Nazwy plików są stabilne i mogą grupować rodzinę kodów, np. +`GOV-TICKET-ALLOCATION.md`. Linki muszą być względne i pozostawać wewnątrz +`error/`. diff --git a/.governance/generate_required_checks.py b/.governance/generate_required_checks.py new file mode 100644 index 0000000..9b38e1a --- /dev/null +++ b/.governance/generate_required_checks.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +"""Derive a repository's required-checks declaration from its own workflows. + +Every adopter currently ships the hub's copy verbatim: repository +`wellmanifest/new-project`, workflowFile `.github/workflows/ci.yml`, checks +`test` and `windows-governance`. Twenty of them do not have that workflow at +all, so the declared single source of truth for check names is false almost +everywhere, and `GOV-SYNC-001` blocks adoption until it is corrected by hand. + +The truth is already in the repository: the job names its pull-request +workflows publish. This derives the declaration from them. + +Read-only unless --write is given. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +SCHEMA = "new-project.required-checks/v1" +JOB_LINE = re.compile(r"^ ([A-Za-z0-9][A-Za-z0-9_-]*):\s*(?:#.*)?$") +JOB_NAME_LINE = re.compile(r"^ name:\s*(.+?)\s*$") +TOP_LEVEL_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*:") +REUSABLE_CALL = re.compile(r"^ (?:uses:|['\"]uses['\"]:)") +IMMUTABLE_CALL = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}") +CONTRACT_PATH = Path('.github/reusable-workflows.lock.json') +CONTRACT_SCHEMA = 'new-project.reusable-workflows/v1' +DECLARATION_CANDIDATES = ( + Path(".governance/required-checks.json"), + Path("governance/required-checks.json"), +) +IGNORED_FIELD = "circularGovernanceChecksIgnoredByValidator" +HUB_REPOSITORY = "wellmanifest/new-project" + + +def scalar(raw: str) -> str: + value = raw.strip() + if value.startswith('"'): + try: + decoded, end = json.JSONDecoder().raw_decode(value) + except json.JSONDecodeError as exc: + raise SystemExit('unsupported double-quoted workflow scalar') from exc + if value[end:].strip() and not value[end:].lstrip().startswith('#'): + raise SystemExit('invalid trailing workflow scalar content') + return decoded + if value.startswith("'"): + match = re.fullmatch(r"'((?:[^']|'')*)'\s*(?:#.*)?", value) + if match is None: + raise SystemExit('unsupported single-quoted workflow scalar') + return match.group(1).replace("''", "'") + if " #" in value: + value = value.split(" #", 1)[0].rstrip() + return value + + +def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result = {} + for key, value in pairs: + if key in result: + raise SystemExit(f'duplicate reusable workflow contract key: {key}') + result[key] = value + return result + + +def contained_source(root: Path, relative: str) -> Path: + path = Path(relative) + if (path.is_absolute() or '..' in path.parts or '\\' in relative + or not re.fullmatch(r'\.github/reusable-workflows/[A-Za-z0-9_.-]+\.ya?ml', relative) + or path.as_posix() != relative): + raise SystemExit('unsafe reusable workflow source path') + candidate = root + for part in path.parts: + candidate /= part + if candidate.is_symlink(): + raise SystemExit('symlinked reusable workflow source') + if not candidate.is_file(): + raise SystemExit('missing reusable workflow source') + return candidate + + +def reusable_sources(root: Path) -> dict[str, str]: + """Read reviewed source bindings; this never fetches or executes workflows. + + The SHA binds the callee reference and the digest binds its reviewed bytes. + Neither the lock nor these bytes are independent publication approval. + """ + path = root / CONTRACT_PATH + if not path.exists() and not path.is_symlink(): + return {} + if path.is_symlink() or (root / '.github').is_symlink(): + raise SystemExit('symlinked reusable workflow contract') + try: + document = json.loads(path.read_text(encoding='utf-8'), object_pairs_hook=unique_object) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise SystemExit(f'invalid reusable workflow contract: {exc}') from exc + if (not isinstance(document, dict) or set(document) != {'schema', 'workflows'} + or document['schema'] != CONTRACT_SCHEMA + or not isinstance(document['workflows'], list) or not document['workflows']): + raise SystemExit('invalid reusable workflow contract shape') + sources = {} + for item in document['workflows']: + if not isinstance(item, dict) or set(item) != {'uses', 'sourceFile', 'sourceSha256'}: + raise SystemExit('invalid reusable workflow source binding') + ref, relative, digest = item['uses'], item['sourceFile'], item['sourceSha256'] + if not isinstance(ref, str) or not IMMUTABLE_CALL.fullmatch(ref): + raise SystemExit('reusable workflow must use a full immutable commit SHA') + if ref in sources: + raise SystemExit('duplicate reusable workflow reference') + if not isinstance(relative, str) or not isinstance(digest, str) or not re.fullmatch('[0-9a-f]{64}', digest): + raise SystemExit('invalid reusable workflow source digest or path') + source = contained_source(root, relative).read_bytes() + if hashlib.sha256(source).hexdigest() != digest: + raise SystemExit('reusable workflow source digest mismatch') + try: + sources[ref] = source.decode('utf-8') + except UnicodeError as exc: + raise SystemExit('reusable workflow source must be UTF-8') from exc + return sources + + +def literal_jobs(text: str) -> list[tuple[str, str]]: + """Only literal block-mapping job declarations have an offline contract.""" + jobs = [] + current = None + body = [] + in_jobs = False + top_keys = set() + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith('#'): + continue + if '\t' in line[:len(line) - len(line.lstrip())]: + raise SystemExit('unsupported reusable workflow indentation') + if not line.startswith(' '): + if not TOP_LEVEL_KEY.match(line): + raise SystemExit('unsupported reusable workflow top-level mapping') + key = line.split(':', 1)[0] + if key in top_keys: + raise SystemExit('duplicate reusable workflow top-level key') + top_keys.add(key) + if TOP_LEVEL_KEY.match(line): + if current is not None: + jobs.append((current, '\n'.join(body))) + current, body = None, [] + in_jobs = bool(re.fullmatch(r'jobs:\s*(?:#.*)?', line)) + continue + if not in_jobs or not line.strip() or line.lstrip().startswith('#'): + continue + match = JOB_LINE.match(line) + if match: + if current is not None: + jobs.append((current, '\n'.join(body))) + current, body = match.group(1), [] + elif line.startswith(' ') and not line.startswith(' '): + raise SystemExit('unsupported reusable workflow job mapping') + elif current is not None: + if line.startswith(' ') and not line.startswith(' '): + if not re.match(r'^ [A-Za-z_][A-Za-z0-9_-]*:', line): + raise SystemExit('unsupported reusable workflow job fields') + body.append(line) + else: + raise SystemExit('unsupported reusable workflow jobs') + if current is not None: + jobs.append((current, '\n'.join(body))) + if not jobs or len({key for key, _ in jobs}) != len(jobs): + raise SystemExit('missing or duplicate reusable workflow jobs') + return jobs + + +def literal_name(key: str, body: str) -> str: + names = [scalar(m.group(1)) for line in body.splitlines() if (m := JOB_NAME_LINE.match(line))] + name = names[0] if names else key + if len(names) > 1 or not name or '${{' in name or name.startswith(('*', '&', '|', '>')): + raise SystemExit('unsupported reusable workflow job name') + return name + + +def callee_names(text: str) -> list[str]: + active = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('#')) + if not re.search(r'^on:\s*(?:workflow_call\s*(?:#.*)?$|\n\s+workflow_call:)', active, re.M): + raise SystemExit('source is not a reusable workflow_call workflow') + names = [] + for key, body in literal_jobs(text): + fields = [line.split(':', 1)[0].strip() for line in body.splitlines() + if re.match(r'^ [A-Za-z_][A-Za-z0-9_-]*:', line)] + if len(fields) != len(set(fields)): + raise SystemExit('duplicate reusable workflow job field') + if re.search(r'^ (?:uses|strategy|if):', body, re.M): + raise SystemExit('nested, matrix or conditional reusable jobs are unsupported') + names.append(literal_name(key, body)) + if len(names) != len(set(names)): + raise SystemExit('duplicate reusable workflow check names') + return names + + +def resolved_checks_text(text: str, sources: dict[str, str], callers: list[str]) -> list[str]: + if not pull_request_gating(text): + return [] + unresolved = [] + names = published_checks_text(text, unresolved) + active = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('#')) + if (not unresolved and re.search( + r'^ [A-Za-z0-9][A-Za-z0-9_-]*:\s*\{.*\buses\b', active, re.M)): + raise SystemExit('unsupported reusable workflow caller mapping') + if not unresolved: + return names + for key, body in literal_jobs(text): + calls = [scalar(line.split('uses:', 1)[1]) for line in body.splitlines() + if REUSABLE_CALL.match(line)] + if not calls: + continue + name = literal_name(key, body) + if len(calls) != 1: + raise SystemExit('duplicate reusable workflow uses') + source = sources.get(calls[0]) + if source is None: + callers.append(name) + continue + if re.search(r'^ (?:strategy|if):', body, re.M): + raise SystemExit('matrix or conditional reusable callers are unsupported') + names.extend(f'{name} / {leaf}' for leaf in callee_names(source)) + if len(names) != len(set(names)): + raise SystemExit('duplicate published reusable check names') + return names + + +def _trigger_line(line: str, *, complete: bool = False) -> str: + """Strip YAML comments while retaining hashes inside quoted scalars.""" + quote = None + containers = [] + index = 0 + while index < len(line): + char = line[index] + if quote == '"' and char == '\\': + index += 2 + continue + if quote == "'" and line[index:index + 2] == "''": + index += 2 + continue + if char == quote: + quote = None + elif quote is None and char in "'\"": + quote = char + elif quote is None and char == '#' and (index == 0 or line[index - 1].isspace()): + line = line[:index] + break + elif complete and quote is None and char in '[{': + containers.append(char) + elif complete and quote is None and char in ']}': + if not containers or containers.pop() != {']': '[', '}': '{'}[char]: + raise SystemExit('unsupported workflow metadata flow value') + index += 1 + if complete and (quote is not None or containers): + raise SystemExit('unsupported multiline workflow metadata value') + return line.rstrip() + + +def _trigger_word(value: str) -> str: + if not re.fullmatch(r'''(?:[a-z][a-z0-9_]*|'[a-z][a-z0-9_]*'|"[a-z][a-z0-9_]*")''', value): + raise SystemExit('unsupported literal workflow trigger scalar') + return value.strip("'\"") + + +def _trigger_sequence(value: str, children: list[str]) -> list[str]: + if value: + combined = ' '.join([value, *(line.strip() for line in children)]) + if not (combined.startswith('[') and combined.endswith(']')): + raise SystemExit('unsupported workflow trigger sequence') + items = combined[1:-1].split(',') + if items[-1].strip() == '': + items.pop() # YAML permits a trailing comma. + else: + items = [] + indentation = None + for line in children: + match = re.fullmatch(r'( *)-\s+(.+)', line) + if match is None or (indentation is not None and len(match[1]) != indentation): + raise SystemExit('unsupported workflow trigger sequence') + indentation = len(match[1]) + items.append(match[2]) + if not items: + raise SystemExit('empty workflow trigger sequence') + return [_trigger_word(item.strip()) for item in items] + + +def _trigger_mapping(lines: list[str], *, indentless_lists: bool = False) -> dict[str, tuple[str, list[str]]]: + """Read literal block fields; reject aliases, merge keys and mixed forms.""" + result: dict[str, tuple[str, list[str]]] = {} + indentation = len(lines[0]) - len(lines[0].lstrip()) if lines else 0 + current = None + for line in lines: + depth = len(line) - len(line.lstrip()) + if '\t' in line[:depth] or depth < indentation: + raise SystemExit('unsupported workflow trigger indentation') + if depth == indentation and not line.lstrip().startswith('- '): + match = re.fullmatch(r'''\s*([a-z][a-z0-9_]*|'[a-z][a-z0-9_]*'|"[a-z][a-z0-9_]*"):\s*(.*)''', line) + if match is None: + raise SystemExit('unsupported workflow trigger mapping') + current = _trigger_word(match[1]) + if current in result: + raise SystemExit('duplicate workflow trigger field') + result[current] = (match[2], []) + elif current is None or (depth == indentation and not indentless_lists): + raise SystemExit('unsupported workflow trigger mapping') + else: + result[current][1].append(line) + return result + + +def pull_request_gating(text: str) -> bool: + """Inspect only literal on.pull_request, never sibling events or payload text. + + Like literal_jobs, this is an offline subset reader, not a general YAML + parser. Ambiguous or unsupported trigger forms must refuse derivation. + """ + lines = [active for line in text.splitlines() if (active := _trigger_line(line)).strip()] + # Establish literal root boundaries before searching for on. A multiline + # quoted/flow metadata value may otherwise contain a line resembling a key. + roots = set() + root_key, root_value = None, '' + for line in lines: + if line.startswith((' ', '\t')) or (line == '---' and not roots): + continue + if line.startswith('- ') and root_key == 'on' and not root_value: + continue + match = re.fullmatch(r'''([A-Za-z_][A-Za-z0-9_-]*|'[A-Za-z_][A-Za-z0-9_-]*'|"[A-Za-z_][A-Za-z0-9_-]*")\s*:\s*(.*)''', line) + if match is None: + raise SystemExit('unsupported literal workflow root mapping') + root_key, root_value = match[1].strip("'\""), match[2] + if root_key in roots: + raise SystemExit('duplicate workflow root field') + roots.add(root_key) + if root_key != 'on': + if root_value.startswith(("'", '"')): + scalar(root_value) + elif root_value.startswith(('[', '{')): + _trigger_line(root_value, complete=True) + triggers = [(index, match[1]) for index, line in enumerate(lines) + if (match := re.fullmatch(r'''(?:on|'on'|"on")\s*:\s*(.*)''', line))] + if not triggers: + return False + if len(triggers) != 1: + raise SystemExit('duplicate workflow on declaration') + index, value = triggers[0] + children = [] + for line in lines[index + 1:]: + if not line.startswith((' ', '\t', '- ')): + break + children.append(line) + if value.startswith('[') or (not value and children and children[0].lstrip().startswith('- ')): + return 'pull_request' in _trigger_sequence(value, children) + if value: + if children: + raise SystemExit('unsupported workflow trigger scalar children') + return _trigger_word(value) == 'pull_request' + events = _trigger_mapping(children) + if 'pull_request' not in events: + return False + value, children = events['pull_request'] + if value not in ('', 'null', '~', '{}') or (value and children): + raise SystemExit('unsupported pull_request trigger mapping') + fields = _trigger_mapping(children, indentless_lists=True) + if 'types' not in fields: + return True + types, children = fields['types'] + return any(activity != 'closed' for activity in _trigger_sequence(types, children)) + + +def published_checks_text(text: str, callers: list[str]) -> list[str]: + """Direct display names and unresolved reusable caller display names.""" + if not pull_request_gating(text): + return [] + names: list[str] = [] + current: str | None = None + calls_reusable = False + in_jobs = False + + def flush() -> None: + if current is None: + return + (callers if calls_reusable else names).append(current) + + for line in text.splitlines(): + if TOP_LEVEL_KEY.match(line): + # Only the jobs mapping publishes check contexts; on:, env: and the + # rest use the same two-space indentation for their own keys. + flush() + current, calls_reusable = None, False + in_jobs = line.startswith("jobs:") + continue + if not in_jobs: + continue + job = JOB_LINE.match(line) + if job: + flush() + current, calls_reusable = job.group(1), False + continue + if current is None: + continue + display = JOB_NAME_LINE.match(line) + if display: + current = scalar(display.group(1)) + continue + if REUSABLE_CALL.match(line): + calls_reusable = True + flush() + return names + + +def published_checks(workflow: Path, callers: list[str]) -> list[str]: + return published_checks_text(workflow.read_text(encoding="utf-8"), callers) + + +def repository_name(root: Path) -> str | None: + try: + url = subprocess.run( + ["git", "-C", str(root), "remote", "get-url", "origin"], + capture_output=True, text=True, check=False, timeout=10, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return None + match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", url) + return match.group(1) if match else None + + +def declaration_for( + root: Path, + ignored: tuple[str, ...] = (), + workflow_payloads: dict[str, bytes] | None = None, + repository: str | None = None, +) -> dict[str, Any] | None: + repository = repository or repository_name(root) + if repository is None: + return None + directory = root / ".github/workflows" + overlays = workflow_payloads or {} + ignored_set = set(ignored) + workflows: dict[str, tuple[Path, bytes | None]] = {} + if directory.is_dir(): + for workflow in sorted(directory.glob("*.y*ml")): + relative = workflow.relative_to(root).as_posix() + if relative not in ignored_set and workflow.name not in ignored_set: + workflows[relative] = (workflow, None) + for relative, content in overlays.items(): + if not relative.startswith(".github/workflows/") or not relative.endswith((".yml", ".yaml")): + continue + if relative not in ignored_set and Path(relative).name not in ignored_set: + workflows[relative] = (root / relative, content) + if not workflows: + return None + checks: list[dict[str, str]] = [] + callers: list[str] = [] + sources = reusable_sources(root) + for relative, (workflow, content) in sorted(workflows.items()): + text = content.decode('utf-8') if content is not None else workflow.read_text(encoding='utf-8') + names = resolved_checks_text(text, sources, callers) + for name in names: + checks.append({"name": name, "workflowFile": relative}) + if not checks and not callers: + return None + document: dict[str, Any] = { + "schema": SCHEMA, + "version": 1, + "repository": repository, + "requiredChecks": checks, + } + if callers: + document["reusableWorkflowCallers"] = sorted(set(callers)) + return document + + +def declaration_path(root: Path) -> Path: + """The hub keeps its instance in governance/, adopters in .governance/.""" + for candidate in DECLARATION_CANDIDATES: + if (root / candidate).is_file(): + return root / candidate + return root / DECLARATION_CANDIDATES[0] + + +def current_declaration(root: Path) -> dict[str, Any] | None: + path = declaration_path(root) + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def declared_names(document: dict[str, Any] | None) -> list[str]: + if not document: + return [] + names = document.get("requiredCheckNames") + if names is None: + names = [item.get("name") for item in document.get("requiredChecks", [])] + return sorted(str(name) for name in names or []) + + +def inspect_declaration(root: Path, write: bool) -> dict[str, Any]: + current = current_declaration(root) + inherited_hub_declaration = (current or {}).get("repository") == HUB_REPOSITORY + ignored = () if inherited_hub_declaration else tuple((current or {}).get(IGNORED_FIELD, ()) or ()) + derived = declaration_for(root) + if derived is not None and ignored: + derived[IGNORED_FIELD] = list(ignored) + entry = { + "repository": root.name, + "derived": derived, + "currentRepository": (current or {}).get("repository"), + "currentNames": declared_names(current), + "derivedNames": declared_names(derived), + } + entry["agrees"] = ( + derived is not None + and entry["currentRepository"] == derived["repository"] + and entry["currentNames"] == entry["derivedNames"] + ) + entry["reusableWorkflowCallers"] = (derived or {}).get("reusableWorkflowCallers", []) + if write and derived is not None and not entry["agrees"]: + if entry["reusableWorkflowCallers"]: + entry["written"] = False # A caller's context name cannot be derived here. + else: + declaration_path(root).write_text( + json.dumps(derived, indent=2) + "\n", encoding="utf-8" + ) + entry["written"] = True + return entry + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("roots", nargs="+", help="Repository roots to inspect") + parser.add_argument("--write", action="store_true", help="Rewrite each declaration in place") + parser.add_argument("--format", choices=("text", "json"), default="text") + args = parser.parse_args(argv or sys.argv[1:]) + + report: list[dict[str, Any]] = [] + for raw in args.roots: + entry = inspect_declaration(Path(raw).resolve(), args.write) + report.append(entry) + + if args.format == "json": + print(json.dumps(report, indent=2)) + else: + for entry in report: + state = "agrees" if entry["agrees"] else "DIFFERS" + print(f"{entry['repository']:<24} {state}") + if not entry["agrees"]: + print(f" declared {entry['currentRepository']} {entry['currentNames']}") + derived_repo = (entry["derived"] or {}).get("repository") + print(f" derived {derived_repo} {entry['derivedNames']}") + if entry["reusableWorkflowCallers"]: + print( + " callers " + f"{entry['reusableWorkflowCallers']} publish " + " / ; confirm those names by hand" + ) + print(f"\n{sum(1 for e in report if e['agrees'])} of {len(report)} agree") + return 0 +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/governance_check.py b/.governance/governance_check.py new file mode 100755 index 0000000..c7bb64d --- /dev/null +++ b/.governance/governance_check.py @@ -0,0 +1,5433 @@ +#!/usr/bin/env python3 +"""Deterministic policy-as-code validator for new-project target repositories.""" + +from __future__ import annotations + +import argparse +import ast +import fnmatch +import hashlib +import importlib.util +import json +import os +import re +import stat +import subprocess +import sys +import time +from collections.abc import Iterable +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +try: # Python 3.11+ + import tomllib +except ImportError: + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: + tomllib = None # type: ignore[assignment] + +# Managed validators are read-only checks. Importing adjacent managed modules +# must not create `__pycache__` inside the repository and turn a clean checkout +# into an implementation diff on the next validation pass. +_previous_bytecode_policy = sys.dont_write_bytecode +sys.dont_write_bytecode = True +try: + try: + from ticket_activity import (ActivityError, delivery_landed, + parse_ticket_state_text as _shared_ticket_state, + resolve as resolve_ticket_activity) + except ModuleNotFoundError: + _activity_spec = importlib.util.spec_from_file_location( + "ticket_activity", Path(__file__).with_name("ticket_activity.py") + ) + if _activity_spec is None or _activity_spec.loader is None: + raise + _activity_module = importlib.util.module_from_spec(_activity_spec) + sys.modules[_activity_spec.name] = _activity_module + _activity_spec.loader.exec_module(_activity_module) + ActivityError = _activity_module.ActivityError + delivery_landed = _activity_module.delivery_landed + resolve_ticket_activity = _activity_module.resolve + _shared_ticket_state = _activity_module.parse_ticket_state_text +finally: + sys.dont_write_bytecode = _previous_bytecode_policy + +_previous_bytecode_policy = sys.dont_write_bytecode +sys.dont_write_bytecode = True +try: + try: + from repository_policy import load_adapter, policy_error, selected_policy + except ModuleNotFoundError: + _policy_spec = importlib.util.spec_from_file_location( + "repository_policy", Path(__file__).with_name("repository_policy.py") + ) + if _policy_spec is None or _policy_spec.loader is None: + raise + _policy_module = importlib.util.module_from_spec(_policy_spec) + sys.modules[_policy_spec.name] = _policy_module + _policy_spec.loader.exec_module(_policy_module) + load_adapter = _policy_module.load_adapter + policy_error = _policy_module.policy_error + selected_policy = _policy_module.selected_policy +finally: + sys.dont_write_bytecode = _previous_bytecode_policy + +RUNTIME_VERSION = "0.11.0" +POLICY_DSL_LOCK = { + "schema": "new-project.policy-dsl-lock/v1", + "dependency": { + "id": "wellmanifest/policy-dsl", + "version": "0.2.0-dev", + "sourceRepository": "wellmanifest/policy-dsl", + "sourceRevision": "0ffa41efd903d3e0fc3dbf4431f2771b34c1f70e", + "sourcePath": "tests/policy_dsl_check.py", + "sourceSha256": "15439706ef54953ff90a20bbd2a89c9fef2e6ef9ec7a423025d9d597c78a848c" + }, + "installation": { + "packageSourcePath": "scripts/policy_dsl_check.py", + "managedTargetPath": ".governance/policy_dsl_check.py", + "lockTargetPath": ".governance/policy-dsl.lock.json", + "networkRequired": False + } +} +ACTIVE_DEFAULT = {"IN_PROGRESS"} +TICKET_ACTIVITY_ERROR = "GOV-TICKET-ACTIVITY-001" +EXECUTABLE_SUFFIXES = { + ".bat", ".c", ".cc", ".cmd", ".cpp", ".go", ".java", ".js", ".jsx", + ".mjs", ".php", ".ps1", ".py", ".rb", ".rs", ".sh", ".ts", ".tsx", +} +SECRET_RE = re.compile( + r"(?i)(api[_-]?key|access[_-]?key|client[_-]?secret|password|private[_-]?key|token)" + r"[ \t]*[:=][ \t]*['\"]?([A-Za-z0-9_./+=-]{12,})" +) +SECRET_FIELD_NAME_RE = re.compile( + r"(?i)(api[_-]?key|access[_-]?key|client[_-]?secret|password|private[_-]?key|token)" +) +SAFE_SECRET_VALUES = re.compile(r"(?i)^(example|placeholder|changeme|your[_-]|\$\{|<|xxx|test)") +GENERATED_SECRET_PLACEHOLDER_RE = re.compile(r"^__GENERATE_[A-Z0-9_]+__$") +LOCAL_PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/](?:Users|Documents|Desktop)[\\/]|/(?:home|Users)/[^/\s]+/)") +IMMUTABLE_IMAGE_RE = re.compile(r"^[^@\s]+@sha256:[a-f0-9]{64}$") +COMPOSE_IMAGE_RE = re.compile( + r"^\s*image\s*:\s*(?:\"([^\"]+)\"|'([^']+)'|([^\s#]+))" +) +DOMAIN_CONTRACTS_CQRS = { + "mode": "cqrs", + "commandsAndQueries": "operations/index.json", + "events": "events/index.json", + "errors": "error/index.json", + "models": "operations/index.json#/models", +} + + +@dataclass(order=True) +class Finding: + code: str + severity: str + message: str + remediation: str + paths: list[str] = field(default_factory=list, compare=False) + evidence: dict[str, Any] = field(default_factory=dict, compare=False) + + +@dataclass +class TicketRecord: + directory: Path + status: str | None + workflow: str | None + intent: dict[str, Any] | None + intent_error: str | None + files: dict[str, tuple[bytes, str]] | None = None + external: bool = False + + +class Report: + def __init__(self, root: Path, timing: bool = False) -> None: + self.root = root + self.timing = timing + self.timings: dict[str, float] = {} + self.findings: list[Finding] = [] + self.snapshot_migrations: dict[str, dict[str, Any]] = {} + self.cached: bool = False + + def record_timing(self, phase: str, duration: float) -> None: + if self.timing: + self.timings[phase] = round(duration, 4) + + def add( + self, + code: str, + message: str, + remediation: str, + paths: Iterable[str] = (), + evidence: dict[str, Any] | None = None, + severity: str = "error", + ) -> None: + self.findings.append(Finding( + code=code, + severity=severity, + message=message, + remediation=remediation, + paths=sorted(set(paths)), + evidence=evidence or {}, + )) + + @property + def errors(self) -> int: + return sum(item.severity == "error" for item in self.findings) + + def payload(self) -> dict[str, Any]: + findings = sorted(self.findings) + data = { + "schema": "new-project.governance-report/v1", + "runtimeVersion": RUNTIME_VERSION, + "validation_scope": "static-governance", + "runtime_enforcement": "not_checked", + "grants_execution_authority": False, + "root": ".", + "status": "passed" if self.errors == 0 else "failed", + "summary": { + "errors": self.errors, + "warnings": sum(item.severity == "warning" for item in findings), + "findings": len(findings), + }, + "findings": [asdict(item) for item in findings], + } + if self.cached: + data["cached"] = True + if self.timings: + data["timings"] = self.timings + return data + + +def load_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def work_classification_header_error(value: Any) -> str | None: + fields = {"$schema", "schema", "dimensions", "ordering", "priorityDerivation", "evaluation", "rules"} + if not isinstance(value, dict) or set(value) != fields: + return "work classification contract fields are invalid" + if value.get("$schema") != "./work-classification.schema.json": + return "work classification schema reference drifted" + if value.get("schema") != "new-project.work-classification/v1": + return "unsupported work classification schema" + if value.get("dimensions") != { + "kind": ["BUG", "FEATURE", "SERVICE"], + "priority": ["P0", "P1", "P2", "P3"], + "origin": ["regression", "requested", "health"], + }: + return "work classification dimensions or order drifted" + ordering = value.get("ordering") + if ordering != { + "precedence": ["dependencies", "kind", "priority", "stableId"], + "kindOrder": ["BUG", "FEATURE", "SERVICE"], + "priorityOrder": ["P0", "P1", "P2", "P3"], + "dependencyPolicy": "topological-before-ranking", + "stableIdPolicy": "lexicographic", + }: + return "work classification precedence drifted" + if value.get("priorityDerivation") != { + "impact": {"critical": "P0", "high": "P1", "medium": "P2", "low": "P3"}, + "declaredPolicy": "require-valid-priority", + "serviceDefault": "P2", + }: + return "work classification priority derivation drifted" + evaluation = value.get("evaluation") + if not isinstance(evaluation, dict) or evaluation != { + "mode": "first-match", "unmatchedPolicy": "reject", "llmRole": "advisory-only", + }: + return "work classification evaluation policy drifted" + return None + + +def complexity_rule_assignment(when: dict[str, Any]) -> tuple[tuple[str, str] | None, str | None]: + if when.get("baseline") == "measured" and ( + when.get("delta") == "increased" or when.get("threshold") == "crossed" + ): + return ("BUG", "regression"), "impact" + if when == { + "signal": "cyclomatic-complexity", + "baseline": "pre-existing", + "delta": "not-increased", + }: + return ("SERVICE", "health"), "service-default" + return None, None + + +def expected_rule_assignment(when: dict[str, Any]) -> tuple[tuple[str, str] | None, str | None]: + signal = when.get("signal") + if signal == "defect" and when.get("impact") in {"outage-or-security", "functional"}: + return ("BUG", "regression"), "impact" + if signal == "cyclomatic-complexity": + return complexity_rule_assignment(when) + if signal == "work-request" and when.get("request") == "new-behavior": + return ("FEATURE", "requested"), "declared" + if signal == "work-request" and when.get("request") == "maintenance": + return ("SERVICE", "health"), "service-default" + return None, None + + +def work_classification_rule_error(rule: dict[str, Any]) -> str | None: + if set(rule) != {"id", "when", "assign", "prioritySource"}: + return "work classification rule fields are invalid" + when = rule.get("when") + assignment = rule.get("assign") + if not isinstance(when, dict) or not isinstance(assignment, dict): + return "work classification rule condition or assignment is invalid" + expected_when_fields = { + "defect": [{"signal", "impact"}], + "cyclomatic-complexity": [ + {"signal", "baseline", "delta"}, + {"signal", "baseline", "threshold"}, + ], + "work-request": [{"signal", "request"}], + }.get(when.get("signal")) + if expected_when_fields is None or set(when) not in expected_when_fields: + return f"work classification rule {rule['id']} mixes incompatible signal fields" + expected_assignment, expected_priority_source = expected_rule_assignment(when) + if expected_assignment is None: + return f"work classification rule {rule['id']} has invalid condition values" + if set(assignment) != {"kind", "origin"}: + return f"work classification rule {rule['id']} has an invalid assignment" + actual_assignment = assignment.get("kind"), assignment.get("origin") + if actual_assignment != expected_assignment: + return f"work classification rule {rule['id']} has an invalid assignment" + if rule.get("prioritySource") != expected_priority_source: + return f"work classification rule {rule['id']} has an invalid priority source" + return None + + +def work_classification_error(value: Any) -> str | None: + header_error = work_classification_header_error(value) + if header_error: + return header_error + assert isinstance(value, dict) + rules = value.get("rules") + if not isinstance(rules, list) or len(rules) != 7: + return "work classification must contain exactly seven rules" + identifiers = [rule.get("id") for rule in rules if isinstance(rule, dict)] + expected_identifiers = [f"W-CLASS-{index:03d}" for index in range(1, 8)] + if identifiers != expected_identifiers: + return "work classification rule identifiers or first-match order drifted" + for rule in rules: + assert isinstance(rule, dict) + rule_error = work_classification_rule_error(rule) + if rule_error: + return rule_error + return None + + +def load_work_classification( + root: Path, + report: Report, + raw_path: str = ".governance/work-classification.dsl.json", +) -> dict[str, Any] | None: + try: + path = safe_repo_path(root, raw_path) + if not path.is_file() and raw_path == ".governance/work-classification.dsl.json": + hub_path = safe_repo_path(root, "governance/work-classification.dsl.json") + if hub_path.is_file(): + path = hub_path + value = load_json(path) + error = work_classification_error(value) + if error: + raise ValueError(error) + except (OSError, ValueError, json.JSONDecodeError) as error: + report.add( + "GOV-MANIFEST-001", + f"Work classification contract is invalid: {error}", + "Restore the managed work-classification DSL from the pinned standard release.", + [raw_path], + ) + return None + return value + + +def rel(root: Path, path: Path) -> str: + return path.relative_to(root).as_posix() + + +def safe_repo_path(root: Path, raw: str) -> Path: + candidate = (root / raw).resolve() + try: + candidate.relative_to(root) + except ValueError as error: + raise ValueError(f"path escapes repository: {raw}") from error + return candidate + + +def resolve_policy_dsl_dependency(root: Path) -> tuple[Path, dict[str, Any]]: + """Resolve and byte-verify the reviewed Policy DSL runtime without network I/O.""" + managed_lock = root / POLICY_DSL_LOCK["installation"]["lockTargetPath"] + if managed_lock.is_file(): + lock_path = managed_lock + checker_path = root / POLICY_DSL_LOCK["installation"]["managedTargetPath"] + else: + lock_path = root / "governance/policy-dsl.lock.json" + checker_path = root / POLICY_DSL_LOCK["installation"]["packageSourcePath"] + + lock = load_json(lock_path) + if lock != POLICY_DSL_LOCK: + raise ValueError("Policy DSL lock differs from the reviewed closed dependency record") + if not checker_path.is_file(): + raise ValueError("Policy DSL checker is missing") + actual = hashlib.sha256(checker_path.read_bytes()).hexdigest() + expected = POLICY_DSL_LOCK["dependency"]["sourceSha256"] + if actual != expected: + raise ValueError(f"Policy DSL checker digest differs: expected={expected}, actual={actual}") + return checker_path, lock + + +def load_policy_dsl_module(root: Path) -> Any: + checker_path, _ = resolve_policy_dsl_dependency(root) + name_digest = hashlib.sha256(str(checker_path).encode("utf-8")).hexdigest()[:16] + module_name = f"_new_project_policy_dsl_{name_digest}" + existing = sys.modules.get(module_name) + if existing is not None: + return existing + spec = importlib.util.spec_from_file_location(module_name, checker_path) + if spec is None or spec.loader is None: + raise ValueError("Policy DSL checker cannot be imported") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(module_name, None) + raise + return module + + +HOST_MERGE_START = "" +HOST_MERGE_END = "" +# The superseded block granted merge on green and administrative bypass; every +# observed copy cites this revision of wellmanifest/merge. +STALE_HOST_MERGE_SIGNATURE = "wellmanifest/merge@ticket-008" + + +def check_host_merge_policy(root: Path, report: Report) -> None: + """Warn while host instructions still carry the superseded merge authority.""" + stale = [] + for name in ("AGENTS.md", "CLAUDE.md", "GEMINI.md"): + path = root / name + if not path.is_file() or path.is_symlink(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + begin, end = text.find(HOST_MERGE_START), text.find(HOST_MERGE_END) + block = text[begin:end] if 0 <= begin < end else "" + if STALE_HOST_MERGE_SIGNATURE in block: + stale.append(name) + if stale: + report.add( + "GOV-HOST-MERGE-001", + "Host instructions carry the superseded autonomous-merge block that allows " + "agents to merge on green or bypass review.", + "Preview and apply scripts/host_publication_policy.py from the standard in an owned " + "ticket; only the independent Validator may approve and merge.", + stale, + severity="warning", + ) + + +def check_required_checks_declaration(root: Path, report: Report) -> None: + script = next( + ( + candidate + for candidate in ( + root / "scripts" / "check_required_checks.py", + root / ".governance" / "check_required_checks.py", + ) + if candidate.is_file() + ), + None, + ) + if script is None: + return + try: + spec = importlib.util.spec_from_file_location("_new_project_required_checks", script) + if spec is None or spec.loader is None: + raise ValueError("required-checks gate cannot be imported") + module = importlib.util.module_from_spec(spec) + previous_bytecode_policy = sys.dont_write_bytecode + try: + sys.dont_write_bytecode = True + spec.loader.exec_module(module) + finally: + sys.dont_write_bytecode = previous_bytecode_policy + code = module.main(["--root", str(root)]) + except SystemExit as error: + code = error.code if isinstance(error.code, int) else 1 + except Exception as error: + report.add( + "GOV-SYNC-001", + f"Required-checks gate could not run: {error}", + "Restore scripts/check_required_checks.py or .governance/check_required_checks.py.", + [rel(root, script)], + ) + return + if code: + report.add( + "GOV-SYNC-001", + "Required-checks declaration does not match published workflow job names.", + "Write the repository instance with the check names the ruleset enforces, using each job's name: field.", + ["governance/required-checks.json", ".governance/required-checks.json"], + ) + + +def check_policy_dsl(root: Path, report: Report) -> None: + contributing = root / "CONTRIBUTING.md" + if not contributing.is_file(): + return + try: + source = contributing.read_text(encoding="utf-8") + # Only a document that opts into the Policy DSL with a dsl fence is validated. + if not any(line.strip() == "```dsl" for line in source.splitlines()): + return + policy_dsl = load_policy_dsl_module(root) + policy_dsl.parse_markdown(source) + except Exception as error: + report.add( + "GOV-POLICY-DSL-001", + f"CONTRIBUTING.md or its pinned Policy DSL runtime is invalid: {error}", + "Restore the managed Policy DSL files or correct the selected dsl fences, then rerun the governance gate.", + ["CONTRIBUTING.md"], + ) + + +def string_list(value: Any, *, nonempty: bool = False) -> bool: + return ( + isinstance(value, list) + and (not nonempty or bool(value)) + and all(isinstance(item, str) and bool(item) for item in value) + and len(value) == len(set(value)) + ) + + +def relative_pattern(value: str) -> bool: + normalized = value.replace("\\", "/") + return ( + not normalized.startswith("/") + and not re.match(r"^[A-Za-z]:/", normalized) + and ".." not in normalized.split("/") + ) + + +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.com/attestations/validator/v1" + ) + + +BRANCH_NAME = re.compile('^(?!@?$)(?!HEAD$)(?![-/])(?![\\s\\S]*(?:\\.\\.|//|@\\{|[ ~^:?*\\[\\\\\\x00-\\x1f\\x7f]))(?![\\s\\S]*(?:^|/)\\.)(?![\\s\\S]*(?:^|/)[^/]*\\.lock(?:/|$))(?![\\s\\S]*[/.]$)[\\s\\S]+$') + + +def branch_name(value: Any) -> bool: + return isinstance(value, str) and BRANCH_NAME.fullmatch(value) is not 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"] <= 240, + 1 <= value["checkpointMinutes"] < value["maxActiveMinutes"], + value["maxImplementationFiles"] >= 1, + value["maxAffectedComponents"] >= 1, + value["maxPublicInterfaceChanges"] >= 0, + value["maxRuntimeDependencies"] >= 0, + ]) + + +DELIVERY_BUDGET_FIELDS = { + "maxImplementationFiles", "maxAffectedComponents", + "maxPublicInterfaceChanges", "maxRuntimeDependencies", +} + + +def delivery_profile_valid(value: Any) -> bool: + if not isinstance(value, dict) or set(value) != DELIVERY_BUDGET_FIELDS: + return False + if not integer_fields_valid(value, DELIVERY_BUDGET_FIELDS): + return False + return ( + value["maxImplementationFiles"] >= 1 + and value["maxAffectedComponents"] >= 1 + and value["maxPublicInterfaceChanges"] >= 0 + and value["maxRuntimeDependencies"] >= 0 + ) + + +def delivery_classes_valid(value: Any) -> bool: + return ( + string_list(value, nonempty=True) + and len(set(value)) == len(value) + and set(value) <= {"XS", "S", "M", "L"} + ) + + +def delivery_profiles_valid(value: dict[str, Any], classes_valid: bool) -> bool: + profiles = value.get("profiles") + return profiles is None or ( + classes_valid + and isinstance(profiles, dict) + and set(profiles) == set(value["allowedComplexityClasses"]) + and all(delivery_profile_valid(profile) for profile in profiles.values()) + and all( + profile[field] <= value[field] + for profile in profiles.values() + for field in DELIVERY_BUDGET_FIELDS + ) + ) + + +def delivery_policy_valid(value: Any) -> bool: + fields = { + "requiredForImplementation", "maxActiveMinutes", "checkpointMinutes", + "allowedComplexityClasses", "maxImplementationFiles", + "maxAffectedComponents", "maxPublicInterfaceChanges", + "maxRuntimeDependencies", "targetBranches", "publicInterfacePaths", + "dependencyManifestPaths", + } + allowed_fields = {frozenset(fields), frozenset(fields | {"profiles"})} + if not isinstance(value, dict) or frozenset(value) not in allowed_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 = delivery_classes_valid(value.get("allowedComplexityClasses")) + profiles_valid = delivery_profiles_valid(value, classes_valid) + 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 profiles_valid and targets_valid and paths_valid + + +def effective_delivery_policy(policy: dict[str, Any], complexity: str) -> dict[str, Any]: + profile = policy.get("profiles", {}).get(complexity) + return policy if profile is None else {**policy, **profile} + + +# Where each recognized delivery field belongs, so a misplaced one names its required path. +DELIVERY_FIELD_PATHS = { + **{name: f"delivery.{name}" for name in ( + "acceptedBaseSha", "targetBranch", "outcome", "nonGoals", "complexity", "estimatedMinutes", + "budgets", "architecture", "runtimeDependencies", "validation")}, + **{name: f"delivery.budgets.{name}" for name in ( + "maxImplementationFiles", "maxAffectedComponents", "maxPublicInterfaceChanges", "maxRuntimeDependencies")}, + **{name: f"delivery.architecture.{name}" for name in ( + "status", "decision", "components", "responsibilityChanges", "interfaceChanges", "dataChanges", "ui", "rollback")}, +} + + +def field_set_hint(value: Any, required: set[str], optional: set[str] = frozenset(), + located: dict[str, str] | None = None) -> str: + """Name the missing and unexpected keys so an agent can repair an intent in one edit.""" + if not isinstance(value, dict): + return f" (expected an object with {', '.join(sorted(required))}; got {type(value).__name__})" + missing = sorted(required - set(value)) + unexpected = sorted(set(value) - required - set(optional)) + parts = [f"missing: {', '.join(missing)}"] if missing else [] + if unexpected: + parts.append(f"unexpected: {', '.join(unexpected)}") + parts.extend(f"move {name} to {located[name]}" for name in unexpected if name in (located or {})) + return f" ({'; '.join(parts)})" if parts else "" + + +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", "M", "L"}: + return f"delivery complexity must be XS, S, M or L (got {value.get('complexity')!r})" + minutes = value.get("estimatedMinutes") + if not isinstance(minutes, int) or isinstance(minutes, bool) or not 1 <= minutes <= 240: + return "delivery estimatedMinutes must be between 1 and 240" + 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" + field_set_hint(budgets, fields, located=DELIVERY_FIELD_PATHS) + 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" + field_set_hint(component, {"name", "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" + field_set_hint(ui, {"impact", "states", "evidence"}) + + '; for non-UI work use {"impact": "none", "states": [], "evidence": []}') + if ui.get("impact") not in {"none", "single-state", "multi-state"}: + return f"delivery UI impact is invalid (got {ui.get('impact')!r}; allowed: none, single-state, multi-state)" + if not string_list(ui.get("states")) or not set(ui["states"]) <= {"loading", "empty", "error", "success"}: + return (f"delivery UI states are invalid (got {ui.get('states')!r}; " + "expected a unique list drawn from loading, empty, error, success)") + 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 " + f"(got states={states!r}, evidence={evidence!r}; set both to [] or choose a UI impact)") + if impact == "single-state" and (len(states) != 1 or not evidence): + return (f"single-state UI work requires one state and planned evidence " + f"(got {len(states)} state(s), {len(evidence)} evidence item(s))") + if impact == "multi-state" and (len(states) < 2 or not evidence): + return (f"multi-state UI work requires at least two states and planned evidence " + f"(got {len(states)} state(s), {len(evidence)} evidence item(s))") + return None + + +DATA_CHANGE_KINDS = { + "component-local-state", "schema-migration", "cross-component-migration", + "ownership-transfer", "unknown", +} + + +def delivery_data_changes_error(changes: Any, components: Any) -> str | None: + """Legacy prose stays conservative; local state needs an explicit owner.""" + if not isinstance(changes, list): + return "delivery architecture dataChanges must be a list" + names = [item.get("name") for item in components if isinstance(item, dict)] if isinstance(components, list) else [] + seen = set() + for change in changes: + if isinstance(change, str): + if not change.strip(): + return "delivery data change description is blank" + elif isinstance(change, dict): + if set(change) != {"kind", "component", "description"}: + return "delivery data change requires kind, component and description" + if not isinstance(change["kind"], str) or change["kind"] not in DATA_CHANGE_KINDS: + return "delivery data change kind is unknown" + if not isinstance(change["component"], str) or names.count(change["component"]) != 1: + return "delivery data change component must resolve to one declared component" + if not isinstance(change["description"], str) or not change["description"].strip(): + return "delivery data change description is blank" + else: + return "delivery data change must be legacy prose or a typed record" + key = json.dumps(change, sort_keys=True) + if key in seen: + return "delivery data changes must be unique" + seen.add(key) + return None + + +def integration_data_changes(changes: list[Any]) -> list[Any]: + # Do not infer an exemption from prose, spelling or an unknown record. + return [change for change in changes if not ( + isinstance(change, dict) and change.get("kind") == "component-local-state" + )] + + +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" + field_set_hint( + architecture, fields, located=DELIVERY_FIELD_PATHS) + if architecture.get("status") != "accepted": + return f"delivery architecture status must be accepted before implementation (got {architecture.get('status')!r})" + 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" + if not string_list(architecture.get("interfaceChanges")): + return "delivery architecture interfaceChanges must be a unique string list" + return (delivery_components_error(architecture.get("components")) + or delivery_data_changes_error(architecture.get("dataChanges"), 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" + field_set_hint(item, {"criterion", "commands", "evidence"}) + 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 + + +PHYSICAL_CONTRACT_FILES = (".governance/physical-contracts.json", "governance/physical-contracts.json") +PHYSICAL_PROPERTIES = { + "pin-assignment", "active-level", "pull", "capability-set", + "unit-scale", "limit-range", "timing", "none", +} +PHYSICAL_HAZARDS = {"motion", "pressure", "electrical", "thermal", "none"} + + +def physical_contracts_error(value: Any) -> str | None: + """Adopter declaration of files whose values carry physical meaning.""" + if not isinstance(value, dict) or set(value) != {"schema", "contracts"}: + return "physical contracts must contain exactly schema and contracts" + if value.get("schema") != "new-project.physical-contracts/v1": + return "physical contracts schema is unsupported" + contracts = value.get("contracts") + if not isinstance(contracts, list) or not contracts: + return "physical contracts must declare at least one contract" + ids: list[str] = [] + for contract in contracts: + if not isinstance(contract, dict) or set(contract) != {"id", "paths", "properties", "hazard", "acceptance"}: + return "physical contract requires id, paths, properties, hazard and acceptance" + if not isinstance(contract["id"], str) or re.fullmatch(r"[a-z0-9][a-z0-9.-]*", contract["id"]) is None: + return "physical contract id is invalid" + if not relative_pattern_list(contract["paths"], nonempty=True): + return "physical contract paths must be repository-relative patterns" + if len(contract["paths"]) != len(set(contract["paths"])): + return "physical contract paths must be unique" + if not string_list(contract["properties"], nonempty=True) or not set(contract["properties"]) <= PHYSICAL_PROPERTIES - {"none"}: + return "physical contract properties are invalid" + if len(contract["properties"]) != len(set(contract["properties"])): + return "physical contract properties must be unique" + if not isinstance(contract["hazard"], str) or contract["hazard"] not in PHYSICAL_HAZARDS: + return "physical contract hazard is invalid" + if not isinstance(contract["acceptance"], str) or not contract["acceptance"].strip(): + return "physical contract acceptance procedure is blank" + ids.append(contract["id"]) + return "physical contract ids must be unique" if len(ids) != len(set(ids)) else None + + +def physical_changes_error(changes: Any) -> str | None: + """Every changed physical property is declared on its own, before and after. + + A pin move and a polarity flip are two entries, so neither can hide + inside the other. Each exact contract/property/signal has one claim; + "none" is exclusive with changed properties of the same contract. + """ + if not isinstance(changes, list) or not changes: + return "intent physicalChanges must be a non-empty list" + seen = set() + claims = set() + unchanged_contracts = set() + changed_contracts = set() + for change in changes: + if not isinstance(change, dict) or not isinstance(change.get("property"), str) or change["property"] not in PHYSICAL_PROPERTIES: + return "physical change property is invalid" + if change["property"] == "none": + if set(change) != {"contract", "property", "rationale"}: + return "a no-semantics physical change requires contract, property and rationale" + if not isinstance(change["rationale"], str) or not change["rationale"].strip(): + return "physical change rationale is blank" + else: + if set(change) != {"contract", "property", "signal", "before", "after", "acceptance"}: + return "physical change requires contract, property, signal, before, after and acceptance" + for name in ("signal", "before", "after"): + if not isinstance(change[name], str) or not change[name].strip(): + return f"physical change {name} is blank" + if change["before"] == change["after"]: + return "physical change before and after must differ; use property none" + if not string_list(change["acceptance"], nonempty=True): + return "physical change acceptance must list observable hardware checks" + if not isinstance(change["contract"], str) or not change["contract"].strip(): + return "physical change contract is blank" + key = json.dumps(change, sort_keys=True) + if key in seen: + return "physical changes must be unique" + seen.add(key) + identity = (change["contract"], change["property"], change.get("signal")) + if identity in claims: + return "physical change contract, property and signal must be unique" + claims.add(identity) + if change["property"] == "none": + if change["contract"] in changed_contracts: + return "physical change property none cannot coexist with changes to the same contract" + unchanged_contracts.add(change["contract"]) + else: + if change["contract"] in unchanged_contracts: + return "physical change property none cannot coexist with changes to the same contract" + changed_contracts.add(change["contract"]) + return None + + +def load_physical_contracts(root: Path) -> tuple[Path | None, dict[str, Any] | None, str | None]: + for raw in PHYSICAL_CONTRACT_FILES: + path = root / raw + if path.is_file(): + try: + value = load_json(path) + except (OSError, json.JSONDecodeError) as error: + return path, None, str(error) + return path, value, physical_contracts_error(value) + return None, None, None + + +def check_physical_contract_changes( + root: Path, intent: dict[str, Any], ticket: str, implementation: list[str], report: Report, +) -> None: + path, contracts, error = load_physical_contracts(root) + if path is None: + return + if error: + report.add( + "GOV-PHYS-002", f"Physical contract declaration is invalid: {error}", + "Repair the adopter's physical-contracts.json; the gate fails closed while it is unreadable.", + [rel(root, path)], + ) + return + assert contracts is not None + changes = intent.get("physicalChanges", []) + change_error = physical_changes_error(changes) if "physicalChanges" in intent else None + by_id = {contract["id"]: contract for contract in contracts["contracts"]} + if change_error is None: + for change in changes: + contract = by_id.get(change["contract"]) + if contract is None: + change_error = f"Unknown physical contract '{change['contract']}'" + break + if change["property"] != "none" and change["property"] not in contract["properties"]: + change_error = f"Property '{change['property']}' is not declared by physical contract '{contract['id']}'" + break + if change_error: + report.add( + "GOV-PHYS-001", f"Intent physicalChanges are invalid: {change_error}", + "Name an existing physical contract and one of its declared properties, or property none with a rationale.", + [f"project/{ticket}/intent.json"], {"ticket": ticket}, + ) + return + declared = {change["contract"] for change in changes} + for contract in contracts["contracts"]: + touched = [item for item in implementation if matches(item, contract["paths"])] + if not touched or contract["id"] in declared: + continue + report.add( + "GOV-PHYS-001", + f"Changed paths carry physical contract '{contract['id']}' but the intent declares no physicalChanges for it.", + "Declare each changed property (pin, active level, pull, capability set, scale, range, timing) with before, " + f"after and hardware acceptance checks, or property none with a rationale. Acceptance: {contract['acceptance']}", + touched, {"ticket": ticket, "contract": contract["id"], "hazard": contract["hazard"], + "properties": contract["properties"]}, + ) + + +PLACEMENT_HOMES = {"wellmanifest", "subactor", "semcod", "paxlet-com", "dockuri"} +PLACEMENT_SHAPES = {"domain_pack", "runtime_service", "both"} +PLACEMENT_ADOPT = re.compile(r"^wellmanifest/[a-z0-9][a-z0-9-]*$") + + +def placement_error(value: Any) -> str | None: + required = {"home", "shape"} + allowed = required | {"runtimeOwner", "adopt"} + if not isinstance(value, dict) or not required <= set(value) <= allowed: + return "placement must contain home and shape" + if not isinstance(value.get("home"), str) or value["home"] not in PLACEMENT_HOMES: + return "placement home is invalid" + if not isinstance(value.get("shape"), str) or value["shape"] not in PLACEMENT_SHAPES: + return "placement shape is invalid" + runtime_owner = value.get("runtimeOwner") + if runtime_owner is not None and (not isinstance(runtime_owner, str) or runtime_owner not in PLACEMENT_HOMES): + return "placement runtimeOwner is invalid" + if value["home"] == "wellmanifest" and value["shape"] == "runtime_service": + return "runtime_service must not HOME wellmanifest; ADOPT packs from a product organization" + adopt = value.get("adopt") + if adopt is not None and ( + not string_list(adopt) or not all(PLACEMENT_ADOPT.fullmatch(item) for item in adopt) + ): + return "placement adopt must be wellmanifest/ ids" + return None + + +def managed_target_bindings_error( + value: Any, + *, + field: str, + label: str, +) -> tuple[list[str], str | None]: + if not isinstance(value, list): + return [], f"delivery standardAdoption {field} must be a list" + paths: list[str] = [] + for binding in value: + if not isinstance(binding, dict) or set(binding) != {"path", "baseDigest"}: + return [], f"delivery standardAdoption managed target {label} fields are invalid" + path, digest = binding.get("path"), binding.get("baseDigest") + if ( + not isinstance(path, str) + or not path + or not relative_pattern(path) + or any(character in path for character in "*?[") + ): + return [], f"delivery standardAdoption managed target {label} path is invalid" + if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None: + return [], f"delivery standardAdoption managed target {label} digest is invalid" + paths.append(path) + if len(paths) != len(set(paths)): + return [], f"delivery standardAdoption managed target {label} paths must be unique" + return paths, None + + +def target_owned_transitions_error(value: Any) -> tuple[list[str], str | None]: + if not isinstance(value, list): + return [], "delivery standardAdoption targetOwnedTransitions must be a list" + paths: list[str] = [] + for transition in value: + if not isinstance(transition, dict) or set(transition) != { + "path", "baseDigest", "headDigest", + }: + return [], "delivery standardAdoption target-owned transition fields are invalid" + path = transition.get("path") + base_digest = transition.get("baseDigest") + head_digest = transition.get("headDigest") + if ( + not isinstance(path, str) + or not path + or not relative_pattern(path) + or any(character in path for character in "*?[") + ): + return [], "delivery standardAdoption target-owned transition path is invalid" + if not all( + isinstance(digest, str) and re.fullmatch(r"[0-9a-f]{64}", digest) is not None + for digest in (base_digest, head_digest) + ): + return [], "delivery standardAdoption target-owned transition digest is invalid" + if base_digest == head_digest: + return [], "delivery standardAdoption target-owned transition digests must differ" + paths.append(path) + if len(paths) != len(set(paths)): + return [], "delivery standardAdoption target-owned transition paths must be unique" + return paths, None + + +def standard_adoption_header_error(value: Any) -> str | None: + required_fields = {"sourceRepository", "fromRevision", "toRevision"} + allowed_fields = required_fields | { + "managedTargetTakeovers", + "managedTargetRestorations", + "targetOwnedTransitions", + } + if not isinstance(value, dict) or not required_fields <= set(value) <= allowed_fields: + return "delivery standardAdoption fields are invalid" + if value.get("sourceRepository") != "wellmanifest/new-project": + return "delivery standardAdoption sourceRepository is invalid" + from_revision = value.get("fromRevision") + to_revision = value.get("toRevision") + if from_revision is not None and ( + not isinstance(from_revision, str) + or re.fullmatch(r"[0-9a-f]{40}", from_revision) is None + ): + return "delivery standardAdoption revisions must be full lowercase commit SHAs" + if not isinstance(to_revision, str) or re.fullmatch(r"[0-9a-f]{40}", to_revision) is None: + return "delivery standardAdoption revisions must be full lowercase commit SHAs" + if from_revision == to_revision: + return "delivery standardAdoption revisions must differ" + return None + + +def standard_adoption_error(value: Any) -> str | None: + error = standard_adoption_header_error(value) + if error: + return error + takeover_paths, error = managed_target_bindings_error( + value.get("managedTargetTakeovers", []), + field="managedTargetTakeovers", + label="takeover", + ) + if error: + return error + restoration_paths, error = managed_target_bindings_error( + value.get("managedTargetRestorations", []), + field="managedTargetRestorations", + label="restoration", + ) + if error: + return error + transition_paths, error = target_owned_transitions_error( + value.get("targetOwnedTransitions", []) + ) + if error: + return error + if set(takeover_paths) & set(restoration_paths): + return "delivery standardAdoption managed target paths cannot be both takeover and restoration" + if set(transition_paths) & (set(takeover_paths) | set(restoration_paths)): + return "delivery standardAdoption target-owned transitions cannot overlap managed target bindings" + if value["fromRevision"] is None and (takeover_paths or restoration_paths or transition_paths): + return "initial standard adoption cannot declare target bindings" + return None + + +def snapshot_migration_runtime(): + spec = importlib.util.spec_from_file_location( + "new_project_snapshot_migration", Path(__file__).with_name("snapshot_migration.py"), + ) + if spec is None or spec.loader is None: + raise ValueError("Managed snapshot migration runtime is unavailable") + module = importlib.util.module_from_spec(spec) + previous = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + spec.loader.exec_module(module) + finally: + sys.dont_write_bytecode = previous + return module + + +def prepare_snapshot_migrations(args, root, records, base, changed, report): + candidates = [record for record in records if record.intent is not None + and "snapshotMigration" in record.intent.get("delivery", {}) + and any(path.startswith(rel(root, record.directory) + "/") for path in changed)] + authorization = getattr(args, "migration_authorization", None) + if not candidates: + if authorization: + report.add("GOV-SNAPSHOT-MIGRATION-003", "Grant supplied without a migration ticket.", + "Bind the protected grant to exactly one current migration ticket.") + return set(), set() + try: + if len(candidates) != 1: + raise ValueError("Exactly one migration ticket is required") + record = candidates[0] + branch = getattr(args, "migration_branch", None) + if not branch: + raise ValueError("Authenticated migration head branch is required") + observed_branch = subprocess.run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], + cwd=root, text=True, capture_output=True) + if observed_branch.returncode == 0 and observed_branch.stdout.strip() != branch: + raise ValueError("Current branch differs from the protected migration binding") + proof = snapshot_migration_runtime().prove( + root, record.intent, base=base, head=args.head, + repository=args.expected_repository, branch=branch, + authorization_path=authorization, + authorization_sha256=getattr(args, "migration_authorization_sha256", None), + ) + # An index-only edit is a pending repair even when the worktree still + # matches HEAD. Never exempt staged content from ordinary path checks. + staged = git_output(root, ["diff", "--cached", "--no-ext-diff", "--no-textconv", + "--name-only", "-z", args.head]) + staged_paths = {p for p in staged.decode("utf-8").split("\0") if p} + proof["repairPaths"] = sorted(set(proof["repairPaths"]) | staged_paths) + proof["importedPaths"] = sorted(set(proof["importedPaths"]) - set(proof["repairPaths"])) + proof["unchangedImportedFiles"] = len(proof["importedPaths"]) + except (OSError, ValueError, TypeError, KeyError, UnicodeError, subprocess.CalledProcessError) as error: + report.add(getattr(error, "code", "GOV-SNAPSHOT-MIGRATION-003"), + "Snapshot migration proof was rejected: " + str(error), + "Reobserve the protected subject and follow error/GOV-SNAPSHOT-MIGRATION.md.") + return set(), set() + report.snapshot_migrations[record.directory.name] = proof + return set(proof["historicalTickets"]), set(proof["repairPaths"]) + + +def delivery_intent_error(value: Any) -> str | None: + required_fields = { + "acceptedBaseSha", "targetBranch", "outcome", "nonGoals", + "complexity", "estimatedMinutes", "budgets", "architecture", + "runtimeDependencies", "validation", + } + optional_fields = {"standardAdoption", "snapshotMigration"} + if not isinstance(value, dict) or not required_fields <= set(value) <= required_fields | optional_fields: + return "delivery must contain exactly the bounded-delivery fields" + field_set_hint( + value, required_fields, optional_fields, located=DELIVERY_FIELD_PATHS) + if "snapshotMigration" in value: + try: + migration_error = snapshot_migration_runtime().contract_error(value["snapshotMigration"]) + except (OSError, ValueError): + return "managed snapshot migration contract validator is unavailable" + if migration_error: + return migration_error + 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" + if "standardAdoption" in value: + error = standard_adoption_error(value["standardAdoption"]) + if error: + return error + return delivery_validation_error(value.get("validation")) + + +def matches(path: str, patterns: Iterable[str]) -> bool: + path_parts = path.replace("\\", "/").strip("/").split("/") + + def match_pattern(pattern: str) -> bool: + pattern_parts = pattern.replace("\\", "/").strip("/").split("/") + memo: dict[tuple[int, int], bool] = {} + + def visit(path_index: int, pattern_index: int) -> bool: + key = (path_index, pattern_index) + if key in memo: + return memo[key] + if pattern_index == len(pattern_parts): + result = path_index == len(path_parts) + elif pattern_parts[pattern_index] == "**": + result = visit(path_index, pattern_index + 1) or ( + path_index < len(path_parts) and visit(path_index + 1, pattern_index) + ) + else: + result = ( + path_index < len(path_parts) + and fnmatch.fnmatchcase(path_parts[path_index], pattern_parts[pattern_index]) + and visit(path_index + 1, pattern_index + 1) + ) + memo[key] = result + return result + + return visit(0, 0) + + return any(match_pattern(pattern) for pattern in patterns) + + +def segment_literal_prefix(pattern: str) -> str: + index = min((pattern.find(char) for char in "*?[" if char in pattern), default=len(pattern)) + return pattern[:index] + + +def segment_literal_suffix(pattern: str) -> str: + indexes = [pattern.rfind(char) for char in "*?]" if char in pattern] + return pattern[max(indexes, default=-1) + 1:] + + +def segments_may_overlap(first: str, second: str) -> bool: + first_magic = any(char in first for char in "*?[") + second_magic = any(char in second for char in "*?[") + if not first_magic and not second_magic: + return first == second + if not first_magic: + return fnmatch.fnmatchcase(first, second) + if not second_magic: + return fnmatch.fnmatchcase(second, first) + first_prefix = segment_literal_prefix(first) + second_prefix = segment_literal_prefix(second) + if first_prefix and second_prefix and not ( + first_prefix.startswith(second_prefix) or second_prefix.startswith(first_prefix) + ): + return False + first_suffix = segment_literal_suffix(first) + second_suffix = segment_literal_suffix(second) + return not ( + first_suffix + and second_suffix + and not ( + first_suffix.endswith(second_suffix) or second_suffix.endswith(first_suffix) + ) + ) + + +def patterns_may_overlap(first: str, second: str) -> bool: + first_parts = first.replace("\\", "/").strip("/").split("/") + 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: + return memo[key] + if first_index == len(first_parts) and second_index == len(second_parts): + result = True + elif first_index == len(first_parts): + result = remaining_are_globstars(second_parts, second_index) + elif second_index == len(second_parts): + result = remaining_are_globstars(first_parts, first_index) + elif first_parts[first_index] == "**": + result = visit(first_index + 1, second_index) or visit(first_index, second_index + 1) + elif second_parts[second_index] == "**": + result = visit(first_index, second_index + 1) or visit(first_index + 1, second_index) + else: + result = segments_may_overlap(first_parts[first_index], second_parts[second_index]) and visit( + first_index + 1, second_index + 1 + ) + memo[key] = result + return result + + 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 + if not any(char in pattern for char in "*?["): + return matches(pattern, [owner_pattern]) + pattern_parts = pattern.replace("\\", "/").strip("/").split("/") + owner_parts = owner_pattern.replace("\\", "/").strip("/").split("/") + if owner_parts and owner_parts[-1] == "**" and len(pattern_parts) >= len(owner_parts) - 1: + prefix = owner_parts[:-1] + return all( + 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 + + +def git_output(root: Path, args: list[str]) -> bytes: + return subprocess.run( + ["git", *args], cwd=root, check=True, capture_output=True, + ).stdout + + +def changed_paths(root: Path, base: str | None, head: str, explicit: list[str]) -> list[str]: + if explicit: + normalized = sorted({path.replace("\\", "/").removeprefix("./") for path in explicit if path}) + for path in normalized: + safe_repo_path(root, path) + return normalized + try: + if base: + raw = git_output(root, ["diff", "--name-only", "-z", f"{base}...{head}"]) + paths = raw.decode("utf-8", "surrogateescape").split("\0") + else: + tracked = git_output(root, ["diff", "--name-only", "-z", "HEAD"]) + untracked = git_output(root, ["ls-files", "--others", "--exclude-standard", "-z"]) + paths = (tracked + untracked).decode("utf-8", "surrogateescape").split("\0") + return sorted({path for path in paths if path}) + except (subprocess.CalledProcessError, FileNotFoundError) as error: + raise RuntimeError("Git could not determine the changed-path set") from error + + +def check_history_order( + root: Path, + base: str | None, + head: str, + ticket_name: str, + ticket_root: str, + intent_path: str, + governance_patterns: list[str], + report: Report, +) -> None: + if not base: + return + try: + arguments = ["rev-list", "--reverse", f"{base}..{head}"] + migration = report.snapshot_migrations.get(ticket_name) + if migration: + arguments.append("^" + migration["sourceSha"]) + commits = git_output(root, arguments).decode().splitlines() + except (subprocess.CalledProcessError, FileNotFoundError): + report.add( + "GOV-DIFF-001", "Git could not enumerate commits for history-order validation.", + "Fetch the complete base/head history and rerun the governance gate.", + evidence={"base": base, "head": head}, + ) + return + first_implementation: tuple[int, str] | None = None + for index, commit in enumerate(commits): + try: + raw = git_output(root, ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", commit]) + except subprocess.CalledProcessError: + report.add( + "GOV-DIFF-001", f"Git could not inspect commit {commit}.", + "Fetch complete commit objects and rerun the governance gate.", + evidence={"commit": commit}, + ) + return + paths = [path for path in raw.decode("utf-8", "surrogateescape").split("\0") if path] + if any(not matches(path, governance_patterns) for path in paths): + first_implementation = (index, commit) + break + if first_implementation is None: + return + _, commit = first_implementation + ticket_intent = f"{ticket_root.rstrip('/')}/{ticket_name}/{intent_path}" + try: + raw_blob = git_output(root, ["cat-file", "-p", f"{commit}:{ticket_intent}"]) + json.loads(raw_blob.decode("utf-8")) + except (subprocess.CalledProcessError, ValueError, UnicodeDecodeError): + report.add( + "GOV-INTENT-003", + f"{ticket_intent} was absent or invalid in the first material implementation commit.", + "Include the validated intent atomically with the first material commit; do not create a separate plan-only commit.", + [ticket_intent], {"firstImplementationCommit": commit}, + ) + + +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 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, [])) + for name in ("activeStatuses", "nonActiveStatuses", "closedStatuses") + ] + return all([ + ticket.get("requiredFiles") == ["README.md", "intent.json"], + 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 relative_pattern_list(docker.get("dockerfiles"), nonempty=True) + and relative_pattern_list(docker.get("composeFiles"), nonempty=True) + ) + + +def repository_policy_valid(repository: Any) -> bool: + if repository is None: + return True + if ( + not isinstance(repository, dict) + or set(repository) != {"mode", "componentRoots"} + ): + return False + mode = repository.get("mode") + roots = repository.get("componentRoots") + if mode not in {"standalone", "monorepo"} or not relative_pattern_list(roots): + return False + if len(set(roots)) != len(roots): + return False + return (mode == "standalone" and not roots) or (mode == "monorepo" and bool(roots)) + + +def selected_repository_policy_valid(value: Any) -> bool: + return value is None or policy_error(value) is None + + +def domain_contract_policy_valid(domain_contracts: Any) -> bool: + """Keep the optional target contract closed and backwards compatible.""" + return ( + domain_contracts is None + or domain_contracts == {"mode": "none"} + or domain_contracts == DOMAIN_CONTRACTS_CQRS + ) + + +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 idle_takeover_policy_valid(policy: Any) -> bool: + def bounded(value: Any, low: int, high: int) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and low <= value <= high + return ( + isinstance(policy, dict) + and set(policy) == {"enabled", "idleMinutes", "leaseMinutes"} + and isinstance(policy["enabled"], bool) + and bounded(policy["idleMinutes"], 5, 240) + and bounded(policy["leaseMinutes"], 15, 480) + and policy["leaseMinutes"] > policy["idleMinutes"] + ) + + +def coordination_policy_valid(coordination: Any) -> bool: + fields = { + "mode", "maxActiveTicketsPerWorkstream", "rejectActiveScopeOverlap", + "workstreams", "integration", + } + if not isinstance(coordination, dict) or set(coordination) - {"idleTakeover"} != fields: + return False + if "idleTakeover" in coordination and not idle_takeover_policy_valid(coordination["idleTakeover"]): + 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", "approvalEvidence", "ticket", "docker", + "repository", "domainContracts", "coordination", "delivery", "stacks", + "repositoryPolicy", + } + coordination = manifest.get("coordination") + delivery = manifest.get("delivery") + return ( + set(manifest) <= allowed_root_keys + and repository_policy_valid(manifest.get("repository")) + and selected_repository_policy_valid(manifest.get("repositoryPolicy")) + and domain_contract_policy_valid(manifest.get("domainContracts")) + and string_list(manifest.get("stacks", [])) + and set(manifest.get("stacks", [])) <= {"node", "python", "go", "rust", "java", "docker", "frontend", "terraform", "kubernetes"} + 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 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 extension_error(required: Any, candidate: Any, path: str = "$") -> str | None: + if isinstance(required, dict): + if not isinstance(candidate, dict): + return f"{path} must remain an object" + for key, value in required.items(): + if key not in candidate: + return f"{path}/{key} is required by the managed base" + error = extension_error(value, candidate[key], f"{path}/{key}") + if error: + return error + return None + if isinstance(required, list): + if not isinstance(candidate, list): + return f"{path} must remain an array" + for value in required: + if value not in candidate: + return f"{path} removed a value required by the managed base" + return None + if candidate != required: + return f"{path} differs from the managed base" + return None + + +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(): + report.add( + "GOV-SYNC-001", "Governance lock file is missing.", + "Copy the versioned manifest lock from the approved standard adoption.", + [rel(root, lock_path)] if lock_path.is_relative_to(root) else [], + ) + return + try: + 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()): + check_managed_file(root, raw_path, expected, report) + package_path = root / ".governance/package-manifest.json" + if not package_path.is_file(): + return + try: + strategies = package_strategies(package_path.read_bytes()) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as error: + report.add( + "GOV-SYNC-001", f"Governance package manifest is invalid: {error}", + "Restore the pinned package manifest through an explicit standard upgrade.", + [rel(root, package_path)], + ) + return + manifest_target = ".governance/manifest.json" + base_target = ".governance/manifest.base.json" + if strategies.get(manifest_target) != "extendable": + return + if strategies.get(base_target) != "managed" or base_target not in managed: + report.add( + "GOV-SYNC-001", "Extendable governance manifest has no hash-bound managed base.", + "Adopt the complete published package including manifest.base.json.", + [base_target, manifest_target], + ) + return + try: + base = load_json(safe_repo_path(root, base_target)) + error = extension_error(base, manifest) + except (OSError, ValueError, json.JSONDecodeError) as load_error: + error = f"managed manifest base is invalid: {load_error}" + if error: + report.add( + "GOV-SYNC-001", f"Target governance manifest violates its managed base: {error}", + "Restore standard-owned values; keep target changes inside the declared extension fields.", + [base_target, manifest_target], + ) + + +def parse_ticket_state(readme: Path) -> tuple[str | None, str | None]: + try: + text = readme.read_text(encoding="utf-8") + except OSError: + return None, None + return parse_ticket_state_text(text) + + +def parse_ticket_state_text(text: str) -> tuple[str | None, str | None]: + return _shared_ticket_state(text) + + +def ticket_directories(root: Path, config: dict[str, Any]) -> list[Path]: + ticket_root = safe_repo_path(root, config["root"]) + pattern = re.compile(config["directoryPattern"]) + if not ticket_root.is_dir(): + return [] + return sorted( + path for path in ticket_root.iterdir() + if path.is_dir() and not path.is_symlink() and pattern.fullmatch(path.name) + ) + + +def intent_common_error(intent: dict[str, Any], ticket_name: str) -> str | None: + if intent.get("ticket") != ticket_name: + return "intent schema or ticket identity differs" + if not isinstance(intent.get("summary"), str) or not intent["summary"].strip(): + return "intent summary is blank" + for field_name in ("allowedPaths", "forbiddenPaths", "stacks"): + if not string_list(intent.get(field_name)): + return f"intent {field_name} must be a list of non-blank strings" + if not intent["allowedPaths"]: + return "intent allowedPaths is empty" + for field_name in ("allowedPaths", "forbiddenPaths"): + if not all(relative_pattern(value) for value in intent[field_name]): + 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" + if "delivery" in intent: + error = delivery_intent_error(intent["delivery"]) + if error: + return error + if "physicalChanges" in intent: + error = physical_changes_error(intent["physicalChanges"]) + if error: + return error + if "placement" in intent: + return placement_error(intent["placement"]) + return None + + +def intent_classification_error(value: Any) -> str | None: + if not isinstance(value, dict) or set(value) != {"kind", "priority", "origin"}: + return "intent classification must contain kind, priority and origin" + if value.get("kind") not in {"BUG", "FEATURE", "SERVICE"}: + return "intent classification kind is invalid" + if value.get("priority") not in {"P0", "P1", "P2", "P3"}: + return "intent classification priority is invalid" + if value.get("origin") not in {"regression", "requested", "health"}: + return "intent classification origin is invalid" + return 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", "new-project.intent/v3", + }: + return "unsupported intent schema" + expected = v1_fields if intent["schema"] == "new-project.intent/v1" else v2_fields + if intent["schema"] == "new-project.intent/v3": + expected |= {"classification"} + optional = set() if intent["schema"] == "new-project.intent/v1" else {"delivery", "placement", "physicalChanges"} + if not expected <= set(intent) <= expected | optional: + 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) + return validate_intent_value(intent, ticket_name) + + +def validate_intent_value(intent: Any, ticket_name: str) -> tuple[dict[str, Any] | None, str | None]: + 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"] in {"new-project.intent/v2", "new-project.intent/v3"}: + error = intent_v2_error(intent, ticket_name) + if error: + return None, error + if intent["schema"] == "new-project.intent/v3": + error = intent_classification_error(intent.get("classification")) + if error: + return None, error + return intent, None + + +def load_ticket_records(directories: list[Path], config: dict[str, Any]) -> list[TicketRecord]: + records = [] + for directory in directories: + status, workflow = parse_ticket_state(directory / "README.md") + intent, error = validate_intent(directory / config["intentFile"], directory.name) + records.append(TicketRecord(directory, status, workflow, intent, error)) + return records + + +def load_repository_policy_ticket( + root: Path, + manifest: dict[str, Any], + report: Report, +) -> TicketRecord | None: + """Project a declared external ticket into the normal scope checker. + + The projection carries only bounded intent and current state. It never + treats a local adapter as trusted approval or as a replacement for the + external ticket system's protected publication receipt. + """ + policy = selected_policy(manifest) + if policy.get("tickets", {}).get("backend") != "planfile": + return None + adapter, error = load_adapter(root, manifest) + raw_path = policy.get("tickets", {}).get("adapterPath", ".governance/ticket-adapter.json") + if error or adapter is None: + report.add( + "GOV-TICKET-EXTERNAL-001", + f"External ticket adapter is invalid: {error or 'missing adapter'}", + "Create or refresh the repository-local adapter from the active Planfile ticket before changing implementation files.", + [str(raw_path)], + ) + return None + intent = { + "schema": "new-project.intent/v3", + "ticket": adapter["ticket"], + "summary": adapter["summary"], + "allowedPaths": adapter["allowedPaths"], + "forbiddenPaths": adapter["forbiddenPaths"], + "stacks": [], + "workstream": adapter["workstream"], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": None, + "classification": {"kind": "SERVICE", "priority": "P2", "origin": "requested"}, + } + ticket_root = policy.get("tickets", {}).get("root", "project") + return TicketRecord( + root / str(ticket_root) / adapter["ticket"], + adapter["status"], + adapter["workflow"], + intent, + None, + external=True, + ) + + +def load_external_ticket_records(args: argparse.Namespace, root: Path, config: dict[str, Any]) -> list[TicketRecord] | None: + database = getattr(args, "ticket_database", None) + snapshot = getattr(args, "ticket_snapshot", None) + pin = getattr(args, "ticket_snapshot_sha256", None) + if not any((database, snapshot, pin)): + try: + mode = git_output(root, ["config", "--local", "--get", "new-project.ticketStorage"]).decode().strip() + except subprocess.CalledProcessError as error: + # Explicit-path validation also supports an uninitialized scaffold. + # Such a directory has no clone-local opt-in; preserve file mode. + no_repository = error.returncode == 128 and b"--local can only be used inside a git repository" in error.stderr + if error.returncode != 1 and not no_repository: + raise + mode = "files" + if mode == "files": + return None + if mode != "sqlite": + raise ValueError("unknown ticket storage mode") + spec = importlib.util.spec_from_file_location("new_project_ticket_input", Path(__file__).with_name("ticket_input.py")) + if spec is None or spec.loader is None: + raise ValueError("managed ticket input reader missing") + module = importlib.util.module_from_spec(spec) + previous = sys.dont_write_bytecode + try: + sys.dont_write_bytecode = True + spec.loader.exec_module(module) + finally: + sys.dont_write_bytecode = previous + if not any((database, snapshot, pin)): + database = module.primary_database(root) + source = module.load_input(root, database=database, snapshot=snapshot, snapshot_sha256=pin, + repository=args.expected_repository, base=args.base, head=args.head, + protected=args.actor == "ci" or args.enforce_approval) + records = [] + for item in source: + files = item["files"] + readme = files.get("README.md", (b"", "100644"))[0].decode("utf-8") + status, workflow = parse_ticket_state_text(readme) + raw_intent = files.get(config["intentFile"], (b"null", "100644"))[0] + try: + intent, error = validate_intent_value(module.parse_json(raw_intent), item["ticket"]) + except (ValueError, UnicodeError): + intent, error = None, "invalid database intent JSON" + records.append(TicketRecord(root / config["root"] / item["ticket"], status, workflow, intent, error, files)) + return records + + +def active_ticket_records( + root: Path, + config: dict[str, Any], + records: list[TicketRecord], + report: Report | None = None, +) -> list[TicketRecord]: + """Return live reservations from the shared status/receipt resolver.""" + active_statuses = set(config.get("activeStatuses", ACTIVE_DEFAULT)) + active: list[TicketRecord] = [] + for record in records: + if record.status not in active_statuses: + continue + try: + resolution = resolve_ticket_activity(root, record.directory, active_statuses, + **({"status_override": record.status} if record.files is not None else {})) + except ActivityError as error: + # A broken optional cache must never fabricate terminal authority. + # Keep the projection active and expose one stable, recoverable error. + active.append(record) + if report is not None and not any( + finding.code == TICKET_ACTIVITY_ERROR for finding in report.findings + ): + report.add( + TICKET_ACTIVITY_ERROR, + f"Ticket activity could not be resolved safely: {error}", + "Reconcile or quarantine the clone-external registry from protected evidence; follow error/GOV-TICKET-ACTIVITY.md.", + [rel(root, record.directory / "README.md")], + {"ticket": record.directory.name, "fallback": "remain-active"}, + ) + continue + if resolution.active: + active.append(record) + return active + + +def repository_files(root: Path, changed: list[str]) -> list[str]: + try: + raw = git_output(root, ["ls-files", "-co", "--exclude-standard", "-z"]) + files = raw.decode("utf-8", "surrogateescape").split("\0") + except (subprocess.CalledProcessError, FileNotFoundError): + files = [rel(root, path) for path in root.rglob("*") if path.is_file() and ".git" not in path.parts] + return sorted({*files, *changed} - {""}) + + +def valid_active_tickets( + root: Path, + config: dict[str, Any], + active: list[TicketRecord], + workstreams: dict[str, Any], + report: Report, +) -> list[TicketRecord]: + valid: list[TicketRecord] = [] + for record in active: + intent_path = rel(root, record.directory / config["intentFile"]) + if record.intent_error: + report.add( + "GOV-INTENT-002", f"Ticket intent is invalid: {record.intent_error}", + "Create a valid new-project.intent/v3 file before implementation.", [intent_path], + ) + continue + assert record.intent is not None + if record.intent["schema"] != "new-project.intent/v3": + report.add( + "GOV-INTENT-002", f"Active ticket {record.directory.name} lacks deterministic intent/v3 classification.", + "Migrate the active ticket to intent/v3 and declare kind, priority and origin; archived v1/v2 tickets remain readable.", [intent_path], + ) + continue + workstream = record.intent["workstream"] + if workstream not in workstreams: + report.add( + "GOV-WORKSTREAM-001", f"Active ticket {record.directory.name} declares unknown workstream '{workstream}'.", + "Choose a workstream declared in the pinned governance manifest and obtain fresh plan approval.", [intent_path], + {"workstream": workstream, "knownWorkstreams": sorted(workstreams)}, + ) + continue + valid.append(record) + return valid + + +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] + for workstream, members in sorted(grouped.items()): + if len(members) > limit: + report.add( + "GOV-WORKSTREAM-002", f"Workstream '{workstream}' has {len(members)} active tickets; limit is {limit}.", + "Keep active tickets within the configured limit, narrow scopes, or close/block-route competing work.", + [rel(root, member.directory) for member in members], + {"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") in {"new-project.intent/v2", "new-project.intent/v3"}: + graph[record.directory.name] = list(record.intent["dependsOn"]) + if record.directory.name in record.intent["dependsOn"] or record.directory.name in record.intent["conflictsWith"]: + report.add( + "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] = [] + + def visit(name: str, trail: list[str]) -> bool: + if name in visiting: + cycle.extend(trail[trail.index(name):] + [name]) + return True + if name in visited: + return False + visiting.add(name) + for dependency in graph.get(name, []): + if dependency in graph and visit(dependency, [*trail, dependency]): + return True + visiting.remove(name) + visited.add(name) + return False + + for name in sorted(graph): + if visit(name, [name]): + 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") in {"new-project.intent/v2", "new-project.intent/v3"} + and record.intent.get("workstream") == required_workstream + and record.status != "CANCELLED" + ) + + +def adoption_path_list_valid(paths: Any, pattern: str) -> bool: + return ( + isinstance(paths, list) + and bool(paths) + and len(paths) == len(set(paths)) + and all( + isinstance(path, str) + and re.fullmatch(pattern, path) is not None + and relative_pattern(path) + for path in paths + ) + ) + + +def adoption_binding_registry(root: Path) -> tuple[list[str], list[str]] | None: + registry_path = next( + ( + root / candidate + for candidate in ( + ".governance/adoption-bindings.json", + "governance/adoption-bindings.json", + ) + if (root / candidate).is_file() + ), + None, + ) + if registry_path is None: + return None + try: + registry = load_json(registry_path) + except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError): + return None + workflow_paths = registry.get("revisionBoundWorkflowPaths") + target_patterns = registry.get("digestBoundTargetPatterns") + valid = ( + set(registry) == { + "schema", "revisionBoundWorkflowPaths", "digestBoundTargetPatterns", + } + and registry.get("schema") == "new-project.adoption-bindings/v2" + and adoption_path_list_valid(workflow_paths, r"[A-Za-z0-9._/-]+\.ya?ml") + and adoption_path_list_valid(target_patterns, r"\.github/workflows/[A-Za-z0-9._/*?-]+\.ya?ml") + ) + if not valid: + return None + return workflow_paths, target_patterns + + +def add_adoption_workflow_bindings(root: Path, revision: Any, registry: Any, bindings: set[str]) -> None: + if registry is not None and isinstance(revision, str): + try: + workflow_paths, _target_patterns = registry + if re.fullmatch(r"[a-f0-9]{40}", revision) is not None: + uses_pattern = re.compile( + r"(?m)^\s*uses:\s*wellmanifest/new-project/\.github/workflows/" + r"governance\.yml@([a-f0-9]{40})\s*(?:#.*)?$" + ) + ref_pattern = re.compile( + r"(?m)^\s*standard-ref:\s*([a-f0-9]{40})\s*(?:#.*)?$" + ) + for raw_path in workflow_paths: + workflow_path = safe_repo_path(root, raw_path) + if not workflow_path.is_file(): + continue + content = workflow_path.read_text(encoding="utf-8") + if uses_pattern.findall(content) == [revision] and ref_pattern.findall(content) == [revision]: + bindings.add(raw_path) + except (OSError, UnicodeError, TypeError, ValueError): + pass # Lock and ownership validation remain fail closed. + + +def add_adoption_packaging_bindings(root: Path, bindings: set[str]) -> None: + contract_path = next( + ( + root / candidate + for candidate in (".governance/agent-hosts.json", "governance/agent-hosts.json") + if (root / candidate).is_file() + ), + None, + ) + if contract_path is not None: + try: + contract = load_json(contract_path) + packaging = contract.get("packaging", {}) + if isinstance(packaging, dict): + for binding in packaging.values(): + marker = binding.get("marker") if isinstance(binding, dict) else None + if isinstance(marker, str) and relative_pattern(marker) and (root / marker).is_file(): + bindings.add(marker) + except (OSError, json.JSONDecodeError, TypeError, ValueError): + pass # The authoritative agent-host validator reports malformed contracts. + + +def add_adoption_docker_bindings(root: Path, manifest: dict[str, Any], bindings: set[str]) -> None: + docker = manifest.get("docker", {}) + if isinstance(docker, dict) and docker.get("required") is True: + for field in ("dockerfiles", "composeFiles"): + candidates = docker.get(field, []) + if isinstance(candidates, list): + bindings.update( + path for path in candidates + if isinstance(path, str) and relative_pattern(path) and (root / path).is_file() + ) + + +def initial_adoption_seed_paths(root: Path, base: str, revision: str, + manifest: dict[str, Any]) -> set[str]: + """Closed required seeds; neither existing ownership nor writer authority.""" + if not isinstance(base, str) or re.fullmatch(r"[a-f0-9]{40}", base) is None: + return set() + root = root.resolve() + # ls-tree distinguishes proven absence from missing objects/Git failures. + native = [".governance/manifest.lock.json", ".governance/package-manifest.json", + ".governance/manifest.base.json"] + if git_output(root, ["ls-tree", "-z", base, "--", *native]): + return set() + legacy = git_output(root, ["ls-tree", "-z", base, "--", ".governance/manifest.json"]) + if legacy: + if legacy.split(b" ", 1)[0] not in {b"100644", b"100755"}: + return set() + original = json.loads(git_output(root, ["show", base + ":.governance/manifest.json"])) + standard = original.get("standard", {}) if isinstance(original, dict) else {} + if (not isinstance(original, dict) or set(original) != {"schema", "standard"} + or original.get("schema") != "wellmanifest.manifest/v1" + or not isinstance(standard, dict) or set(standard) != {"id", "version"} + or standard.get("id") != "profile:baseline" + or re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", str(standard.get("version", ""))) is None): + return set() + for name in native: + path = root / name + if any(p.is_symlink() for p in (path, *path.parents)) or not path.is_file(): + return set() + lock = load_json(safe_repo_path(root, ".governance/manifest.lock.json")) + if (lock.get("standard", {}).get("sourceRevision") != revision + or lock.get("standard", {}).get("publicationStatus") != "published"): + return set() + base_path = safe_repo_path(root, ".governance/manifest.base.json") + if content_digest(base_path.read_bytes()) != lock.get("managedFiles", {}).get( + ".governance/manifest.base.json"): + return set() + published = load_json(base_path) + package = load_json(safe_repo_path(root, ".governance/package-manifest.json")) + targets = {item["target"] for item in package["files"]} + shared = manifest["coordination"]["integration"]["requiredForPaths"] + seeds = set() + for name in published["requiredFiles"]: + if (not isinstance(name, str) or not relative_pattern(name) + or any(c in name for c in "*?[\\") or name in targets or matches(name, shared)): + continue + path = root / name + if (any(p.is_symlink() for p in (path, *path.parents)) or not path.is_file() + or path.suffix.lower() in EXECUTABLE_SUFFIXES or path.stat().st_mode & 0o111 + or git_output(root, ["ls-tree", "-z", base, "--", name])): + continue + seeds.add(name) + return seeds + + +def atomic_adoption_binding_paths( + root: Path, + manifest: dict[str, Any], + intent: dict[str, Any], +) -> set[str]: + """Return the closed set of target-owned files required during adoption.""" + delivery = intent.get("delivery") + if not isinstance(delivery, dict) or "standardAdoption" not in delivery: + return set() + adoption = delivery["standardAdoption"] + if not isinstance(adoption, dict): + return set() + bindings: set[str] = set() + revision = adoption.get("toRevision") + registry = adoption_binding_registry(root) + add_adoption_workflow_bindings(root, revision, registry, bindings) + add_adoption_packaging_bindings(root, bindings) + add_adoption_docker_bindings(root, manifest, bindings) + if (intent.get("workstream") == "governance" and adoption.get("fromRevision") is None + and standard_adoption_error(adoption) is None): + try: + bindings.update(initial_adoption_seed_paths( + root, delivery.get("acceptedBaseSha"), revision, manifest)) + except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError): + pass # Incomplete seed evidence grants no cross-workstream scope. + return bindings + + +def dependency_is_terminal( + prerequisite: TicketRecord | None, dependency: str, + closed_statuses: set[str], active_statuses: set[str], active_names: set[str], +) -> bool: + verified_terminal = bool( + prerequisite + and prerequisite.status in active_statuses + and dependency not in active_names + ) + return prerequisite is not None and ( + prerequisite.status in closed_statuses or verified_terminal + ) + + +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", [])) + active_statuses = set(config.get("activeStatuses", ACTIVE_DEFAULT)) + 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"] + for record in valid_active: + assert record.intent is not None + for dependency in record.intent["dependsOn"]: + prerequisite = by_name.get(dependency) + if not dependency_is_terminal(prerequisite, dependency, closed_statuses, active_statuses, active_names): + report.add( + "GOV-DEPENDENCY-002", f"Active ticket {record.directory.name} has unfinished or missing dependency {dependency}.", + "Complete the prerequisite or return the dependent ticket to a non-active planning backlog.", + [rel(root, record.directory / config["intentFile"])], + {"ticket": record.directory.name, "dependency": dependency, "dependencyStatus": prerequisite.status if prerequisite else None}, + ) + for conflict in record.intent["conflictsWith"]: + if conflict in active_names: + conflict_pairs.add(tuple(sorted((record.directory.name, conflict)))) + integration_name = record.intent["integrationTicket"] + if integration_name is not None: + integration_record = by_name.get(integration_name) + valid_integration = integration_reference_valid(integration_record, integration_config["workstream"]) + if not valid_integration: + report.add( + "GOV-INTEGRATION-001", + f"Ticket {record.directory.name} references an invalid integration ticket {integration_name}.", + "Reference an existing, non-cancelled ticket in the manifest-declared integration workstream.", + [rel(root, record.directory / config["intentFile"])], + {"ticket": record.directory.name, "integrationTicket": integration_name, "requiredWorkstream": integration_config["workstream"]}, + ) + for first, second in sorted(conflict_pairs): + report.add( + "GOV-CONFLICT-001", f"Conflicting tickets {first} and {second} are active together.", + "Serialize the tickets or resolve the conflict through an approved integration plan.", + [f"project/{first}/intent.json", f"project/{second}/intent.json"], + ) + + +def unowned_scope_files( + record: TicketRecord, files: list[str], governance_patterns: list[str], + adoption_bindings: set[str], owned_paths: list[str], +) -> list[str]: + assert record.intent is not None + return [ + path for path in files + if not matches(path, governance_patterns) + and matches(path, record.intent["allowedPaths"]) + and not matches(path, record.intent["forbiddenPaths"]) + and path not in adoption_bindings + and not matches(path, owned_paths) + ] + + +def check_workstream_claims( + root: Path, + manifest: dict[str, Any], + config: dict[str, Any], + workstreams: dict[str, Any], + governance_patterns: list[str], + files: list[str], + valid_active: list[TicketRecord], + verified_adoption_paths: set[str], + report: Report, +) -> None: + for record in valid_active: + assert record.intent is not None + owned_paths = workstreams[record.intent["workstream"]]["ownedPaths"] + adoption_bindings = ( + atomic_adoption_binding_paths(root, manifest, record.intent) + | verified_adoption_paths + ) + implementation_patterns = [ + pattern for pattern in record.intent["allowedPaths"] + if not matches(pattern, governance_patterns) + ] + unowned_patterns = [ + pattern for pattern in implementation_patterns + if pattern not in adoption_bindings + and not any(pattern_covered_by(pattern, owned) for owned in owned_paths) + ] + unowned_claims = unowned_scope_files(record, files, governance_patterns, adoption_bindings, owned_paths) + if unowned_patterns or unowned_claims: + report.add( + "GOV-WORKSTREAM-003", f"Ticket {record.directory.name} claims paths outside workstream '{record.intent['workstream']}'.", + "Narrow allowedPaths or route the paths to their owning workstream/integration ticket and obtain fresh approval.", + sorted({*unowned_patterns, *unowned_claims})[:20], + { + "ticket": record.directory.name, + "workstream": record.intent["workstream"], + "ownedPaths": owned_paths, + "unownedPatterns": unowned_patterns, + "concretePathCount": len(unowned_claims), + }, + ) + + +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 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)}, + ) + + +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], + verified_adoption_paths: set[str], + report: Report, + active_records: list[TicketRecord] | None = None, +) -> None: + coordination = manifest.get("coordination") + if not isinstance(coordination, dict): + return + config = manifest["ticket"] + check_ticket_statuses(root, config, records, report) + active = active_records if active_records is not None else active_ticket_records(root, config, records, report) + if not changed: + # Ticket records merged into the clean default-branch snapshot are + # authorization history, not evidence of concurrent live writers. A + # current non-empty change selects its lease below; implementation + # authorization remains independently fail-closed in check_change_gate. + active = [] + else: + changed_active = [ + record for record in active + if any(path.startswith(f"{rel(root, record.directory).rstrip('/')}/") for path in changed) + ] + if changed_active: + active = changed_active + elif len(active) > 1: + path_active = [ + record for record in active + if record.intent is not None and any( + matches(path, record.intent["allowedPaths"]) + and not matches(path, record.intent["forbiddenPaths"]) + for path in changed + ) + ] + active = path_active if len(path_active) == 1 else active + 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, manifest, config, workstreams, governance_patterns, files, + valid_active, verified_adoption_paths, report, + ) + if coordination["rejectActiveScopeOverlap"]: + check_scope_overlaps(valid_active, files, governance_patterns, report) + + +def contract_record_index( + value: Any, + identity: str, + fields: set[str], + label: str, +) -> dict[str, dict[str, Any]]: + if not isinstance(value, list) or not value: + raise ValueError(f"{label} must be a nonempty record list") + result: dict[str, dict[str, Any]] = {} + for record in value: + if not isinstance(record, dict) or set(record) != fields: + raise ValueError(f"{label} records must have closed fields") + identifier = record.get(identity) + if not isinstance(identifier, str) or not identifier or identifier in result: + raise ValueError(f"{label} identifiers must be nonempty and unique") + result[identifier] = record + return result + + +def contract_string_list(value: Any, label: str) -> list[str]: + if not isinstance(value, list) or not value: + raise ValueError(f"{label} must be a unique string list") + if not all(isinstance(item, str) and item for item in value): + raise ValueError(f"{label} must be a unique string list") + if len(value) != len(set(value)): + raise ValueError(f"{label} must be a unique string list") + return value + + +def contract_reference_file(root: Path, base: Path, reference: Any, label: str) -> None: + if not isinstance(reference, str) or not reference: + raise ValueError(f"{label} reference must be a nonempty string") + raw_path = reference.split("#", 1)[0] + if not raw_path: + raise ValueError(f"{label} reference needs a transport file") + target = (base / raw_path).resolve() + try: + target.relative_to(root) + except ValueError as error: + raise ValueError(f"{label} reference escapes the repository") from error + if not target.is_file(): + raise ValueError(f"{label} transport file is missing") + + +def contract_document(root: Path, actual: Any, expected: str, label: str) -> None: + if actual != expected or not safe_repo_path(root, expected).is_file(): + raise ValueError(f"{label} documentation must match its stable identifier") + + +def validate_domain_source(root: Path, source: Any) -> None: + if not isinstance(source, dict) or set(source) != { + "commandsAndQueries", "events", "errors", "models", "transportRule", + }: + raise ValueError("operation source-of-truth fields are not closed") + if source.get("commandsAndQueries") != "operations/index.json": + raise ValueError("commands and queries have a noncanonical source") + if source.get("events") != "events/index.json" or source.get("errors") != "error/index.json": + raise ValueError("event or error registry binding is invalid") + if not isinstance(source.get("models"), str) or not source["models"]: + raise ValueError("transport model source must be explicit") + contract_reference_file(root, root, source["models"], "model source") + if not isinstance(source.get("transportRule"), str) or not source["transportRule"]: + raise ValueError("transport authority boundary must be explicit") + + +def validate_domain_operation_header(root: Path, operations: dict[str, Any]): + operation_fields = { + "$schema", "schema", "domain", "sourceOfTruth", "invariants", + "models", "commands", "queries", "projections", + } + if set(operations) != operation_fields: + raise ValueError("operation registry fields are not closed") + if operations.get("schema") != "wellmanifest.operations/v1": + raise ValueError("operation registry schema family is invalid") + contract_reference_file( + root, root / "operations", operations.get("$schema"), "operation schema", + ) + domain = operations.get("domain") + if not isinstance(domain, str) or not domain: + raise ValueError("operation domain must be nonempty") + validate_domain_source(root, operations.get("sourceOfTruth")) + if operations.get("invariants") != { + "commandEffectsBecomeFactsOnlyThroughEvents": True, + "queriesAreEffectFree": True, + "replayExecutesEffects": False, + "eventsCarryAuthority": False, + "modelsCarryAuthority": False, + }: + raise ValueError("CQRS safety invariants are invalid") + return domain + + +def validate_domain_models(root: Path, operations: dict[str, Any]): + models = contract_record_index( + operations.get("models"), "id", + {"id", "schemaRef", "transport", "authority"}, "models", + ) + for model in models.values(): + if model["transport"] not in {"json-schema", "protobuf"}: + raise ValueError("model transport is unsupported") + if model["authority"] is not False: + raise ValueError("transport model must not carry authority") + contract_reference_file(root, root / "operations", model["schemaRef"], "model") + return models + + +def validate_domain_commands(operations: dict[str, Any], models: dict[str, Any]): + commands = contract_record_index( + operations.get("commands"), "id", + { + "id", "uri", "intent", "inputModel", "authority", + "inputCarriesAuthority", "idempotency", "effect", "emits", "rejects", + }, + "commands", + ) + for command in commands.values(): + if command["inputModel"] not in models: + raise ValueError("command input model is unknown") + if not isinstance(command["uri"], str) or not command["uri"]: + raise ValueError("command URI must be nonempty") + if not isinstance(command["intent"], str) or not command["intent"]: + raise ValueError("command intent must be nonempty") + if ( + command["authority"] != "external-policy" + or command["inputCarriesAuthority"] is not False + or command["idempotency"] != "required" + ): + raise ValueError("command authority or idempotency boundary is unsafe") + if not isinstance(command["effect"], str) or command["effect"] in {"", "none"}: + raise ValueError("command must declare a state-changing effect") + contract_string_list(command["emits"], "command events") + contract_string_list(command["rejects"], "command errors") + all_emitted = [item for command in commands.values() for item in command["emits"]] + if len(all_emitted) != len(set(all_emitted)): + raise ValueError("each event must have one command emitter") + return commands, all_emitted + + +def validate_domain_projections(operations: dict[str, Any], models: dict[str, Any]): + projections = contract_record_index( + operations.get("projections"), "id", + {"id", "intent", "outputModel", "cardinality", "rebuiltFrom", "reducer"}, + "projections", + ) + for projection in projections.values(): + if projection["outputModel"] not in models: + raise ValueError("projection output model is unknown") + contract_string_list(projection["rebuiltFrom"], "projection events") + reducer = projection["reducer"] + if ( + not isinstance(reducer, dict) + or set(reducer) != {"version", "deterministic", "effects"} + or not isinstance(reducer["version"], int) + or isinstance(reducer["version"], bool) + or reducer["version"] < 1 + or reducer["deterministic"] is not True + or reducer["effects"] is not False + ): + raise ValueError("projection reducer is not deterministic and effect-free") + return projections + + +def validate_domain_queries(operations: dict[str, Any], models: dict[str, Any], projections: dict[str, Any]): + queries = contract_record_index( + operations.get("queries"), "id", + { + "id", "uri", "intent", "inputModel", "outputModel", "cardinality", + "projection", "consistency", "effect", "emits", + }, + "queries", + ) + for query in queries.values(): + if not isinstance(query["uri"], str) or not query["uri"]: + raise ValueError("query URI must be nonempty") + if not isinstance(query["intent"], str) or not query["intent"]: + raise ValueError("query intent must be nonempty") + if query["inputModel"] not in models or query["outputModel"] not in models: + raise ValueError("query model is unknown") + if query["projection"] not in projections: + raise ValueError("query projection is unknown") + if query["consistency"] not in {"strong", "eventual"}: + raise ValueError("query consistency is unknown") + if query["effect"] != "none" or query["emits"] != []: + raise ValueError("query must be effect-free") + if {query["projection"] for query in queries.values()} != set(projections): + raise ValueError("projection must be owned by exactly this operation registry") + + +def validate_domain_events(root: Path, events: dict[str, Any], domain: str, commands: dict[str, Any], projections: dict[str, Any], all_emitted: list[str]): + if set(events) != {"schema", "domain", "sourceOfTruth", "immutability", "events"}: + raise ValueError("event registry fields are not closed") + if events.get("schema") != "wellmanifest.events/v1" or events.get("domain") != domain: + raise ValueError("event registry domain binding is invalid") + if events.get("sourceOfTruth") != "operations/index.json": + raise ValueError("event registry attempts to redefine operation authority") + if events.get("immutability") != { + "appendOnly": True, + "eventsCarryAuthority": False, + "replayExecutesEffects": False, + }: + raise ValueError("event registry must be append-only and replay-safe") + event_index = contract_record_index( + events.get("events"), "id", + { + "id", "emittedBy", "payloadFields", "documentation", "authority", "replay", + }, + "events", + ) + for event in event_index.values(): + emitter = event["emittedBy"] + if emitter not in commands or event["id"] not in commands[emitter]["emits"]: + raise ValueError("event emitter relation is inconsistent") + contract_string_list(event["payloadFields"], "event payload fields") + if event["authority"] is not False: + raise ValueError("event must not carry authority") + if event["replay"] != {"deterministic": True, "effects": False}: + raise ValueError("event replay must be deterministic and effect-free") + contract_document( + root, event["documentation"], f"events/{event['id']}.md", "event", + ) + rebuilt = { + item for projection in projections.values() for item in projection["rebuiltFrom"] + } + if set(all_emitted) != set(event_index) or rebuilt != set(event_index): + raise ValueError("event registry, command emissions and projections differ") + return event_index + + +def validate_domain_error_status(status: Any) -> None: + if ( + not isinstance(status, dict) + or set(status) != {"http", "grpc"} + or not isinstance(status["http"], int) + or isinstance(status["http"], bool) + or status["http"] < 400 + or status["http"] > 599 + or not isinstance(status["grpc"], str) + or not status["grpc"] + ): + raise ValueError("error transport status is invalid") + + +def validate_domain_errors(root: Path, errors: dict[str, Any], domain: str, commands: dict[str, Any], event_index: dict[str, Any]): + if set(errors) != {"schema", "domain", "sourceOfTruth", "errors"}: + raise ValueError("error registry fields are not closed") + if errors.get("schema") != "wellmanifest.errors/v1" or errors.get("domain") != domain: + raise ValueError("error registry domain binding is invalid") + if errors.get("sourceOfTruth") != "operations/index.json#/commands/*/rejects": + raise ValueError("error registry attempts to redefine command rejection authority") + error_index = contract_record_index( + errors.get("errors"), "code", + {"code", "documentation", "retryability", "status", "rejectionEvent"}, + "errors", + ) + command_for_code = { + code: [cmd_name for cmd_name, cmd in commands.items() if code in cmd["rejects"]] + for code in error_index + } + for error in error_index.values(): + contract_document( + root, error["documentation"], f"error/{error['code']}.md", "error", + ) + if error["rejectionEvent"] not in event_index: + raise ValueError("error rejection event is unknown") + emitter = event_index[error["rejectionEvent"]]["emittedBy"] + if emitter not in command_for_code.get(error["code"], []): + raise ValueError("error rejection event emitter does not match rejecting command") + if error["retryability"] not in { + "never", "after-correction", "after-new-evidence", + }: + raise ValueError("error retryability is unknown") + validate_domain_error_status(error["status"]) + rejected = {item for command in commands.values() for item in command["rejects"]} + if rejected != set(error_index): + raise ValueError("error registry and command rejections differ") + + +def validate_domain_contract_graph( + root: Path, + operations: dict[str, Any], + events: dict[str, Any], + errors: dict[str, Any], +) -> None: + domain = validate_domain_operation_header(root, operations) + models = validate_domain_models(root, operations) + commands, all_emitted = validate_domain_commands(operations, models) + projections = validate_domain_projections(operations, models) + validate_domain_queries(operations, models, projections) + event_index = validate_domain_events(root, events, domain, commands, projections, all_emitted) + validate_domain_errors(root, errors, domain, commands, event_index) + + +def check_domain_contracts(root: Path, manifest: dict[str, Any], report: Report) -> None: + config = manifest.get("domainContracts") + if config is None or config == {"mode": "none"}: + return + assert config == DOMAIN_CONTRACTS_CQRS + raw_paths = [config["commandsAndQueries"], config["events"], config["errors"]] + try: + documents = [load_json(safe_repo_path(root, raw_path)) for raw_path in raw_paths] + if not all(isinstance(document, dict) for document in documents): + raise ValueError("domain contract roots must be JSON objects") + validate_domain_contract_graph(root, *documents) + except (OSError, ValueError, json.JSONDecodeError) as error: + report.add( + "GOV-MANIFEST-001", + f"CQRS domain contract is invalid: {error}", + "Restore the canonical operations, events and error registries and their references.", + raw_paths, + ) + + +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"])], + ) + + +def _section_aware_toml_version(content: str, section_names: tuple[str, ...]) -> str | None: + current_section = None + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + current_section = line.strip("[]").strip() + continue + if current_section in section_names: + m = re.match(r'^version\s*=\s*["\']([^"\']+)["\']', line) + if m: + return m.group(1).strip() + return None + + +def _extract_pyproject_version(content: str) -> str | None: + if tomllib is not None: + try: + data = tomllib.loads(content) + project = data.get("project", {}) + if isinstance(project, dict) and "version" in project and isinstance(project["version"], str): + return project["version"].strip() + poetry = data.get("tool", {}).get("poetry", {}) + if isinstance(poetry, dict) and "version" in poetry and isinstance(poetry["version"], str): + return poetry["version"].strip() + flit = data.get("tool", {}).get("flit", {}).get("metadata", {}) + if isinstance(flit, dict) and "version" in flit and isinstance(flit["version"], str): + return flit["version"].strip() + except Exception: + pass + return _section_aware_toml_version(content, ("project", "tool.poetry", "tool.flit.metadata")) + + +def _extract_setup_py_version(content: str) -> str | None: + m = re.search(r'^\s*version\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + if m: + return m.group(1).strip() + m2 = re.search(r'\bversion\s*=\s*["\']([^"\']+)["\']', content) + return m2.group(1).strip() if m2 else None + + +def _extract_cargo_version(content: str) -> str | None: + if tomllib is not None: + try: + data = tomllib.loads(content) + pkg = data.get("package", {}) + if isinstance(pkg, dict) and "version" in pkg and isinstance(pkg["version"], str): + return pkg["version"].strip() + except Exception: + pass + return _section_aware_toml_version(content, ("package",)) + + +def check_version_parity(root: Path, manifest: dict[str, Any], report: Report) -> None: + try: + version_path = safe_repo_path(root, "VERSION") + except ValueError: + return + if not version_path.is_file(): + return + + try: + raw_version = version_path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + report.add( + "GOV-VERSION-001", + "Authoritative VERSION file is unreadable or contains invalid encoding.", + "Ensure VERSION is an accessible UTF-8 file declaring a valid plain version.", + ["VERSION"], + ) + return + + if not raw_version: + report.add( + "GOV-VERSION-001", + "Authoritative VERSION file is empty.", + "Declare a valid semantic or plain version in VERSION.", + ["VERSION"], + ) + return + + # 1. pyproject.toml + try: + pyproject_path = safe_repo_path(root, "pyproject.toml") + if pyproject_path.is_file(): + content = pyproject_path.read_text(encoding="utf-8") + pyproject_version = _extract_pyproject_version(content) + if pyproject_version and pyproject_version != raw_version: + report.add( + "GOV-VERSION-001", + f"Declared pyproject.toml version '{pyproject_version}' does not match authoritative VERSION '{raw_version}'.", + "Synchronize pyproject.toml version with the VERSION file.", + ["pyproject.toml", "VERSION"], + ) + except (OSError, UnicodeDecodeError, ValueError): + pass + + # 2. setup.py + try: + setup_path = safe_repo_path(root, "setup.py") + if setup_path.is_file(): + content = setup_path.read_text(encoding="utf-8") + setup_version = _extract_setup_py_version(content) + if setup_version and setup_version != raw_version: + report.add( + "GOV-VERSION-001", + f"Declared setup.py version '{setup_version}' does not match authoritative VERSION '{raw_version}'.", + "Synchronize setup.py version with the VERSION file or read VERSION dynamically.", + ["setup.py", "VERSION"], + ) + except (OSError, UnicodeDecodeError, ValueError): + pass + + # 3. package.json + try: + package_json_path = safe_repo_path(root, "package.json") + if package_json_path.is_file(): + pkg_data = json.loads(package_json_path.read_text(encoding="utf-8")) + if isinstance(pkg_data, dict) and "version" in pkg_data: + pkg_version = str(pkg_data["version"]).strip() + if pkg_version and pkg_version != raw_version: + report.add( + "GOV-VERSION-001", + f"Declared package.json version '{pkg_version}' does not match authoritative VERSION '{raw_version}'.", + "Synchronize package.json version with the VERSION file.", + ["package.json", "VERSION"], + ) + except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError): + pass + + # 4. Cargo.toml + try: + cargo_path = safe_repo_path(root, "Cargo.toml") + if cargo_path.is_file(): + content = cargo_path.read_text(encoding="utf-8") + cargo_version = _extract_cargo_version(content) + if cargo_version and cargo_version != raw_version: + report.add( + "GOV-VERSION-001", + f"Declared Cargo.toml version '{cargo_version}' does not match authoritative VERSION '{raw_version}'.", + "Synchronize Cargo.toml version with the VERSION file.", + ["Cargo.toml", "VERSION"], + ) + except (OSError, UnicodeDecodeError, ValueError): + pass + + # 5. Check static __version__ in python packages + skip_dirs = { + ".git", ".venv", "venv", "env", ".subactor", ".worktrees", + "tests", "test", "build", "dist", "node_modules", "fixtures", + "__pycache__", ".tox", ".nox", ".pytest_cache" + } + for init_file in root.glob("**/__init__.py"): + rel_parts = init_file.relative_to(root).parts + if any(part in skip_dirs for part in rel_parts): + continue + try: + text = init_file.read_text(encoding="utf-8") + m = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE) + if m: + found_v = m.group(1).strip() + if found_v != raw_version: + rel_p = rel(root, init_file) + report.add( + "GOV-VERSION-001", + f"Static __version__ '{found_v}' in {rel_p} does not match authoritative VERSION '{raw_version}'.", + "Use dynamic version discovery (importlib.metadata / VERSION) or synchronize __version__ with VERSION.", + [rel_p, "VERSION"], + ) + except (OSError, UnicodeDecodeError): + pass + + +def immutable_image_reference(reference: str) -> bool: + return reference == "scratch" or IMMUTABLE_IMAGE_RE.fullmatch(reference) is not None + + +def dockerfile_image_references(path: Path) -> list[tuple[int, str]]: + references: list[tuple[int, str]] = [] + stage_aliases: set[str] = set() + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + tokens = line.strip().split() + if not tokens or tokens[0].upper() != "FROM": + continue + operands = [token for token in tokens[1:] if not token.startswith("--")] + image = operands[0] if operands else "" + if image.lower() not in stage_aliases: + references.append((line_number, image)) + if len(operands) >= 3 and operands[1].upper() == "AS": + stage_aliases.add(operands[2].lower()) + return references + + +def compose_image_references(path: Path) -> list[tuple[int, str]]: + references: list[tuple[int, str]] = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + match = COMPOSE_IMAGE_RE.match(line) + if match: + references.append((line_number, next(value for value in match.groups() if value is not None))) + return references + + +def check_docker_image_references(root: Path, manifest: dict[str, Any], report: Report) -> None: + docker = manifest["docker"] + invalid: list[tuple[str, int, str]] = [] + for raw_path in docker["dockerfiles"]: + path = safe_repo_path(root, raw_path) + if path.is_file(): + invalid.extend( + (raw_path, line_number, reference) + for line_number, reference in dockerfile_image_references(path) + if not immutable_image_reference(reference) + ) + for raw_path in docker["composeFiles"]: + path = safe_repo_path(root, raw_path) + if path.is_file(): + invalid.extend( + (raw_path, line_number, reference) + for line_number, reference in compose_image_references(path) + if not immutable_image_reference(reference) + ) + if invalid: + report.add( + "GOV-DOCKER-002", + "Docker image references are not pinned to immutable SHA-256 digests.", + "Pin external images as name@sha256:<64 lowercase hex>; for a local-only Compose build, omit image so no mutable tag can be pulled.", + [f"{path}:{line_number}" for path, line_number, _ in invalid], + {"references": [reference for _, _, reference in invalid]}, + ) + + +def check_stacks(root: Path, manifest: dict[str, Any], profiles_path: Path | None, report: Report) -> None: + stacks = manifest.get("stacks", []) + if not stacks or profiles_path is None: + return + try: + profiles = load_json(profiles_path)["profiles"] + if not isinstance(profiles, dict): + raise TypeError("profiles must be an object") + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError): + report.add("GOV-MANIFEST-001", "Stack profile catalog is unreadable.", "Restore the pinned stack profile catalog.", []) + return + for stack in stacks: + profile = profiles.get(stack) + if not isinstance(profile, dict): + report.add("GOV-STACK-001", f"Unknown stack profile: {stack}", "Declare a profile published by the pinned governance standard.", []) + continue + markers = profile.get("anyFiles", []) + if not string_list(markers) or not all(relative_pattern(marker) for marker in markers): + report.add("GOV-MANIFEST-001", f"Stack profile '{stack}' has invalid markers.", "Restore the pinned stack profile catalog.", []) + continue + if markers and not any(safe_repo_path(root, marker).exists() for marker in markers): + report.add("GOV-STACK-001", f"Declared stack '{stack}' has no recognized project marker.", "Add the stack marker or remove the inaccurate stack declaration.", markers) + + +def check_virtual_ticket_content(root, directory, files, active_names, config, report) -> None: + if directory.name in active_names: + missing = [name for name in config["requiredFiles"] if name not in files] + missing += [pattern for pattern in config["requiredAgentFiles"] if not any(fnmatch.fnmatchcase(name, pattern) for name in files)] + if missing: + report.add("GOV-TICKET-003", "Active database ticket is missing required content.", + "Complete the ticket in its database without materializing Git carriers.", + [rel(root, directory / name) for name in missing]) + for name, (content, mode) in files.items(): + if Path(name).suffix.lower() in EXECUTABLE_SUFFIXES or mode == "100755": + report.add("GOV-TICKET-004", "Executable content is forbidden in a database ticket.", + "Move implementation into source or test paths.", [rel(root, directory / name)]) + + +def check_file_ticket_content(root, directory, active_names, config, report) -> None: + if directory.name in active_names: + missing = [rel(root, directory / item) for item in config["requiredFiles"] if not (directory / item).is_file()] + for pattern in config["requiredAgentFiles"]: + if not any(directory.glob(pattern)): + missing.append(rel(root, directory / pattern)) + if missing: + report.add("GOV-TICKET-003", f"Active ticket {directory.name} is missing required governance files.", "Complete the ticket scaffold before implementation.", missing) + for path in directory.rglob("*"): + if not path.is_file(): + continue + mode_executable = bool(path.stat().st_mode & 0o111) + if path.suffix.lower() in EXECUTABLE_SUFFIXES or mode_executable: + report.add( + "GOV-TICKET-004", f"Executable content is forbidden in ticket directory: {rel(root, path)}", + "Move implementation to the repository's normal source, test or scripts directory.", [rel(root, path)], + ) + + +def check_ticket_content( + root: Path, + directories: list[Path], + active: list[TicketRecord], + config: dict[str, Any], + report: Report, + records: list[TicketRecord] | None = None, +) -> None: + active_names = {record.directory.name for record in active} + virtual = {record.directory.name: record.files for record in records or [] if record.files is not None} + for directory in directories: + if directory.name in virtual: + check_virtual_ticket_content(root, directory, virtual[directory.name], active_names, config, report) + else: + check_file_ticket_content(root, directory, active_names, config, report) + + +def extract_constant_string(node: ast.AST) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left = extract_constant_string(node.left) + right = extract_constant_string(node.right) + if left is not None and right is not None: + return left + right + return None + + +def is_secret_literal(node: ast.AST) -> bool: + val = extract_constant_string(node) + if val is not None: + if len(val) >= 12 and not SAFE_SECRET_VALUES.match(val) and not GENERATED_SECRET_PLACEHOLDER_RE.fullmatch(val): + return True + return False + + +def find_python_secrets(tree: ast.AST) -> list[str]: + fields: list[str] = [] + + def add_field(name: str) -> None: + m = SECRET_FIELD_NAME_RE.search(name) + if m: + fields.append(m.group(1)) + + def assigned_literal(target: ast.AST, value: ast.AST) -> None: + if isinstance(target, (ast.Tuple, ast.List)): + if isinstance(value, (ast.Tuple, ast.List)) and len(target.elts) == len(value.elts): + for item, literal in zip(target.elts, value.elts): + assigned_literal(item, literal) + return + if not is_secret_literal(value): + return + if isinstance(target, ast.Name): + add_field(target.id) + elif isinstance(target, ast.Attribute): + add_field(target.attr) + elif isinstance(target, ast.Subscript): + key = extract_constant_string(target.slice) + if key is not None: + add_field(key) + + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + assigned_literal(target, node.value) + elif isinstance(node, ast.AnnAssign): + if node.value is not None: + assigned_literal(node.target, node.value) + elif isinstance(node, ast.Call): + for kw in node.keywords: + if kw.arg and SECRET_FIELD_NAME_RE.search(kw.arg): + if is_secret_literal(kw.value): + add_field(kw.arg) + elif isinstance(node, ast.Dict): + for k, v in zip(node.keys, node.values): + if isinstance(k, ast.Constant) and isinstance(k.value, str) and SECRET_FIELD_NAME_RE.search(k.value): + if is_secret_literal(v): + add_field(k.value) + elif isinstance(node, ast.Compare): + operands = [node.left, *node.comparators] + literal = next((value for value in operands if is_secret_literal(value)), None) + if literal is not None: + for target in operands: + assigned_literal(target, literal) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + pos_args = [*node.args.posonlyargs, *node.args.args] + defaults = node.args.defaults + for arg, default in zip(pos_args[-len(defaults):], defaults): + if SECRET_FIELD_NAME_RE.search(arg.arg) and is_secret_literal(default): + add_field(arg.arg) + for arg, default in zip(node.args.kwonlyargs, node.args.kw_defaults): + if default is not None and SECRET_FIELD_NAME_RE.search(arg.arg) and is_secret_literal(default): + add_field(arg.arg) + return sorted(set(fields)) + + +def probable_secret_fields(text: str, filename: str = "") -> list[str]: + if filename.endswith((".py", ".pyi", ".pyw")): + try: + tree = ast.parse(text) + return find_python_secrets(tree) + except SyntaxError: + pass + fields = [] + for match in SECRET_RE.finditer(text): + value = match.group(2) + shell_assignment = text[match.end(2):].startswith("=") + environment_reference = re.match(r"^[A-Z][A-Z0-9_]*=", value) + safe_generated_placeholder = GENERATED_SECRET_PLACEHOLDER_RE.fullmatch(value) + if ( + not shell_assignment + and not environment_reference + and not SAFE_SECRET_VALUES.match(value) + and not safe_generated_placeholder + ): + 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, raw) + 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], + ) + if fnmatch.fnmatchcase(raw, "project/ticket-*/decisions.md"): + check_decision_log_file(root, raw, text, report) + + +def check_agent_hosts(root: Path, actor: str, report: Report) -> None: + """Prove the host-agnostic contract is installed, not merely documented (ticket-106).""" + if not any( + (root / candidate).is_file() + for candidate in ("governance/agent-hosts.json", ".governance/agent-hosts.json") + ): + return + scripts_dir = Path(__file__).resolve().parent + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from agent_host_check import audit as agent_host_audit + except ImportError: + # The validator is a managed file, so its absence is a sync defect + # rather than a host-contract finding. + report.add( + "GOV-SYNC-001", + "Managed agent host validator is missing next to governance_check.py.", + "Restore agent_host_check.py through an explicit standard upgrade.", + [".governance/agent_host_check.py"], + ) + return + for finding in agent_host_audit(root, actor)["findings"]: + report.add( + finding["code"], finding["message"], finding["remediation"], finding["paths"], + ) + + +def check_decision_log_file(root: Path, raw: str, text: str, report: Report) -> None: + """Validate recomputable decision records (C-DECISION / ticket-031).""" + scripts_dir = Path(__file__).resolve().parent + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from decision_record import parse_dsl_record, split_decision_blocks, validate_record + except ImportError: + report.add( + "GOV-DECISION-002", + f"Cannot import decision_record helper while validating {raw}.", + "Keep scripts/decision_record.py next to governance_check.py.", + [raw], + ) + return + blocks = split_decision_blocks(text) + if not blocks: + report.add( + "GOV-DECISION-001", + f"Decision log {raw} has no DECISION records.", + "Append a fenced ```dsl DECISION record or remove the empty log.", + [raw], + ) + return + for block in blocks: + try: + record = parse_dsl_record(block) + except ValueError as error: + report.add( + "GOV-DECISION-002", + f"Decision record in {raw} is not parseable: {error}", + "Store deterministic INPUT lines and a complete DECISION shape.", + [raw], + ) + continue + for error in validate_record(record): + code = "GOV-DECISION-002" + if "GOV-DECISION-003" in error or "ADVISORY" in error: + code = "GOV-DECISION-003" + elif "GOV-DECISION-004" in error or "replayed verdict" in error: + code = "GOV-DECISION-004" + elif "GOV-DECISION-001" in error: + code = "GOV-DECISION-001" + report.add( + code, + f"Decision record in {raw}: {error}", + "Fix the record so INPUT + APPLIED_RULE recompute VERDICT with DETERMINISTIC authority.", + [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): + report.add( + "GOV-OWNER-001", "Human-owned participant content changed without trusted human intake evidence.", + "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: + profile = policy.get("profiles", {}).get(delivery["complexity"], {}) + limits = effective_delivery_policy(policy, delivery["complexity"]) + complexity_limit = profile.get("maxActiveMinutes", 10 if delivery["complexity"] == "XS" else policy["maxActiveMinutes"]) + declared_limits = delivery["budgets"] + policy_limits = { + "maxImplementationFiles": limits["maxImplementationFiles"], + "maxAffectedComponents": limits["maxAffectedComponents"], + "maxPublicInterfaceChanges": limits["maxPublicInterfaceChanges"], + "maxRuntimeDependencies": limits["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 is_published_integration(root: Path, target: str, supplied_base: str | None) -> bool: + """Recognize one clean integration already observed on the target branch. + + The supplied first parent is the target before this integration. Comparing + the accepted base with the published HEAD would include the ticket's own + changes as intervening drift. Ambiguous ranges and dirty trees retain the + conservative target comparison; this observation never grants approval. + """ + if not supplied_base: + return False + try: + return ( + git_output(root, ["rev-parse", "HEAD"]).decode().strip() == target + and git_output(root, ["rev-parse", f"{target}^1"]).decode().strip() == supplied_base + and not git_output(root, ["status", "--porcelain"]) + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def observe_delivery_bases(root, delivery, accepted_sha, intent_path, base, report): + observed: list[tuple[str, str]] = [] + if base: + try: + observed.append(( + "suppliedBase", + git_output(root, ["rev-parse", f"{base}^{{commit}}"]) + .decode() + .strip(), + )) + except (subprocess.CalledProcessError, FileNotFoundError): + report.add( + "GOV-BASE-001", "The supplied base revision cannot be resolved.", + "Fetch the complete target history and rerun against the accepted base SHA.", + [intent_path], {"suppliedBase": base, "acceptedBaseSha": accepted_sha}, + ) + + 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 + supplied_base = next((sha for source, sha in observed if source == "suppliedBase"), None) + if is_published_integration(root, current_target, supplied_base): + # The accepted-to-supplied-base check below still detects changes + # that landed on the target before this ticket was integrated. + break + observed.append((target_ref, current_target)) + break + + return observed + + +def _is_git_ancestor(root: Path, ancestor_sha: str, descendant_sha: str) -> bool: + result = subprocess.run( + ["git", "merge-base", "--is-ancestor", ancestor_sha, descendant_sha], + cwd=root, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return result.returncode == 0 + + +def check_observed_delivery_bases(root, accepted_commit, observed, component_patterns, record, intent_path, report) -> None: + checked: set[str] = set() + for source, observed_sha in observed: + if observed_sha in checked or observed_sha == accepted_commit: + continue + checked.add(observed_sha) + if not _is_git_ancestor(root, accepted_commit, observed_sha): + report.add( + "GOV-BASE-001", + f"Ticket {record.directory.name} accepted base is not an ancestor of the observed target.", + "Rebuild from the current target and obtain fresh scope and architecture approval.", + [intent_path], + { + "acceptedBaseSha": accepted_commit, + "observedBaseSha": observed_sha, + "source": source, + }, + ) + continue + try: + raw_paths = git_output( + root, + ["diff", "--name-only", "-z", f"{accepted_commit}..{observed_sha}"], + ) + except (subprocess.CalledProcessError, FileNotFoundError): + report.add( + "GOV-BASE-001", "Intervening target changes cannot be inspected.", + "Fetch the complete target history and rerun the overlap check.", + [intent_path], + {"acceptedBaseSha": accepted_commit, "observedBaseSha": observed_sha}, + ) + continue + intervening = sorted( + path + for path in raw_paths.decode("utf-8", "surrogateescape").split("\0") + if path + ) + overlap = [path for path in intervening if matches(path, component_patterns)] + if overlap: + report.add( + "GOV-BASE-002", + f"Target branch changes overlap components approved for {record.directory.name}.", + "Refresh the branch, re-run validation and obtain fresh approval for the overlapping scope.", + [intent_path, *overlap], + { + "acceptedBaseSha": accepted_commit, + "observedBaseSha": observed_sha, + "source": source, + "componentPatterns": component_patterns, + "overlappingPaths": overlap, + }, + ) + + +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"] + component_patterns = [ + pattern + for component in delivery["architecture"]["components"] + for pattern in component["paths"] + ] + observed = observe_delivery_bases(root, delivery, accepted_sha, intent_path, base, report) + + if not observed: + return + try: + accepted_commit = git_output( + root, ["rev-parse", "--verify", f"{accepted_sha}^{{commit}}"] + ).decode().strip() + except (subprocess.CalledProcessError, FileNotFoundError): + report.add( + "GOV-BASE-001", "The accepted base revision cannot be resolved.", + "Fetch the complete target history and rerun against the accepted base SHA.", + [intent_path], {"acceptedBaseSha": accepted_sha}, + ) + return + + check_observed_delivery_bases(root, accepted_commit, observed, component_patterns, record, intent_path, report) + + +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_delivery_architecture( + policy: dict[str, Any], + delivery: dict[str, Any], + record: TicketRecord, + implementation: list[str], + intent_path: str, + report: Report, +) -> set[str]: + limits = effective_delivery_policy(policy, delivery["complexity"]) + declared_limits = delivery["budgets"] + architecture = delivery["architecture"] + components = architecture["components"] + component_overflow = len(components) > min( + declared_limits["maxAffectedComponents"], limits["maxAffectedComponents"], + ) + interface_overflow = len(architecture["interfaceChanges"]) > min( + declared_limits["maxPublicInterfaceChanges"], limits["maxPublicInterfaceChanges"], + ) + dependency_overflow = len(delivery["runtimeDependencies"]) > min( + declared_limits["maxRuntimeDependencies"], limits["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( + limits, 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"]) + migration = report.snapshot_migrations.get(record.directory.name) + if migration: + imported = set(migration["importedPaths"]) + implementation = [path for path in implementation if path not in imported] + 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"] + data_changes = integration_data_changes(architecture["dataChanges"]) + if (architecture["responsibilityChanges"] or data_changes) and record.intent["workstream"] != integration_workstream: + report.add( + "GOV-ARCHITECTURE-001", + "Responsibility or persistent-data movement is not owned by an integration slice.", + "Use integration for responsibility transfers or data migrations. Explicit component-local-state records stay with the component owner; legacy prose remains integration-owned. Reconcile the declared impact and actual diff before requesting new authority.", + [intent_path], + {"workstream": record.intent["workstream"], "requiredWorkstream": integration_workstream, + "responsibilityChanges": architecture["responsibilityChanges"], + "integrationDataChanges": data_changes}, + ) + + +def check_delivery_gate( + root: Path, + manifest: dict[str, Any], + record: TicketRecord, + implementation: list[str], + base: str | None, + elapsed_minutes: int | None, + report: Report, +) -> None: + policy = manifest.get("delivery") + if not isinstance(policy, dict): + return + 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): + explicit_paths = [ + path for path in implementation + if path in policy["dependencyManifestPaths"] + or matches(path, manifest["coordination"]["integration"]["requiredForPaths"]) + ] + if policy.get("requiredForImplementation") or explicit_paths: + report.add( + "GOV-DELIVERY-001", + f"Implementation ticket {record.directory.name} needs an explicit delivery contract.", + "Add delivery architecture and validation evidence for the high-risk paths; routine disjoint source/test changes use the compact intent.", + [intent_path, *explicit_paths], + {"explicitContractPaths": explicit_paths}, + ) + return + public_paths = [ + path for path in implementation + if matches(path, policy["publicInterfacePaths"]) + ] + if ( + len(implementation) > policy["maxImplementationFiles"] + or len(public_paths) > policy["maxPublicInterfaceChanges"] + ): + report.add( + "GOV-BUDGET-001", + f"Routine diff for {record.directory.name} exceeds the policy hard limit.", + "Narrow the change or add an explicit delivery contract; do not create tracking-only split tickets.", + implementation, + { + "implementationFiles": len(implementation), + "implementationFileLimit": policy["maxImplementationFiles"], + "publicInterfacePaths": public_paths, + "publicInterfaceLimit": policy["maxPublicInterfaceChanges"], + }, + ) + 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") in {"new-project.intent/v2", "new-project.intent/v3"} + 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 None + if not isinstance(coordination, dict): + if len(active) > 1: + report.add( + "GOV-TICKET-002", "More than one active ticket exists.", + "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 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 + if selected.files is None and not selected.external: + check_history_order( + root, base=base, head=head, ticket_name=directory.name, + ticket_root=config["root"], + intent_path=config["intentFile"], governance_patterns=governance_patterns, + report=report, + ) + # External ticket chronology belongs to its protected receipt store. The + # pinned input binds repository/base/head; a local DB is never accepted for + # protected validation. Scope, state, base and approval checks still run. + if workflow not in set(config["implementationStates"]): + report.add( + "GOV-INTENT-001", f"Ticket {directory.name} is in workflow state {workflow or 'UNKNOWN'}, not an implementation state.", + "Do not commit the pending change; obtain approval that moves the ticket to EDIT, then deliver it with material output.", implementation, + ) + + +def check_workstream_change_scope( + root: Path, + manifest: dict[str, Any], + records: list[TicketRecord], + coordination: dict[str, Any], + selected: TicketRecord, + implementation: list[str], + report: Report, +) -> None: + intent = selected.intent + assert intent is not None + adoption_bindings = atomic_adoption_binding_paths(root, manifest, intent) + workstream = coordination["workstreams"].get(intent["workstream"]) + if isinstance(workstream, dict): + unowned = [ + path for path in implementation + if path not in adoption_bindings and 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 path not in adoption_bindings and 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: + report.add("GOV-INTENT-002", f"Ticket intent is invalid: {error}", "Create a valid intent file before implementation.", [rel(root, intent_path)]) + else: + outside = [path for path in implementation if not matches(path, intent["allowedPaths"]) or matches(path, intent["forbiddenPaths"])] + if outside: + report.add( + "GOV-SCOPE-001", "Changed implementation paths are outside the ticket intent.", + "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") in {"new-project.intent/v2", "new-project.intent/v3"}: + check_workstream_change_scope(root, manifest, records, coordination, selected, implementation, report) + check_physical_contract_changes(root, intent, directory.name, implementation, report) + if intent is not None and not selected.external: + 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.com/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 git_revision_file(root: Path, revision: str, raw_path: str) -> bytes | None: + try: + return git_output(root, ["show", f"{revision}:{raw_path}"]) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + + +def package_entry(item: Any) -> tuple[str, str, str]: + if not isinstance(item, dict) or set(item) != {"source", "target", "strategy", "executable"}: + raise ValueError("package manifest entry fields are invalid") + source, target = item.get("source"), item.get("target") + if not isinstance(source, str) or not isinstance(target, str): + raise TypeError("package manifest entry is invalid") + if not relative_pattern(source) or not relative_pattern(target): + raise ValueError("package manifest entry is invalid") + if item.get("strategy") not in {"managed", "seed", "extendable"}: + raise ValueError("package manifest entry is invalid") + if not isinstance(item.get("executable"), bool): + raise TypeError("package manifest entry is invalid") + allowed_extendable = { + ("governance/manifest.default.json", ".governance/manifest.json"), + # ticket-206 moved the adopter seed off the hub's own live instance, + # which shipped the hub's identity and job names into every adopter. + # Both sources stay accepted so a repository pinned before that change + # still validates its own manifest while it upgrades. + ("template/files/required-checks.template.json", ".governance/required-checks.json"), + ("governance/required-checks.json", ".governance/required-checks.json"), + ("governance/ticket-allocation.json", ".governance/ticket-allocation.json"), + } + if item.get("strategy") == "extendable" and ( + (source, target) not in allowed_extendable or item.get("executable") + ): + raise ValueError("package manifest extendable target is invalid") + return source, target, item["strategy"] + + +def package_strategies(content: bytes) -> dict[str, str]: + document = json.loads(content) + if not isinstance(document, dict) or set(document) != {"schema", "files"}: + raise ValueError("package manifest fields are invalid") + if document.get("schema") != "new-project.package-manifest/v1" or not isinstance(document.get("files"), list): + raise ValueError("package manifest schema is invalid") + strategies: dict[str, str] = {} + for item in document["files"]: + _source, target, strategy = package_entry(item) + if target in strategies: + raise ValueError("package manifest targets must be unique") + strategies[target] = strategy + if not strategies: + raise ValueError("package manifest is empty") + return strategies + + +def adoption_standard_binding_is_valid(document: dict[str, Any], expected_revision: str) -> bool: + standard = document.get("standard") + if document.get("schema") != "new-project.lock/v1" or not isinstance(standard, dict): + return False + fields = {"id", "version", "sourceRepository", "sourceRevision", "publicationStatus"} + if set(standard) != fields: + return False + expected = { + "id": "wellmanifest/new-project", + "sourceRepository": "wellmanifest/new-project", + "sourceRevision": expected_revision, + "publicationStatus": "published", + } + if any(standard.get(key) != value for key, value in expected.items()): + return False + version = standard.get("version") + return isinstance(version, str) and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) is not None + + +def adoption_lock(content: bytes, expected_revision: str) -> dict[str, str]: + document = json.loads(content) + if not isinstance(document, dict) or set(document) != {"schema", "standard", "managedFiles"}: + raise ValueError("adoption lock fields are invalid") + managed = document.get("managedFiles") + if not adoption_standard_binding_is_valid(document, expected_revision) or not isinstance(managed, dict): + raise ValueError("adoption lock standard binding is invalid") + if not all( + isinstance(path, str) + and relative_pattern(path) + and isinstance(digest, str) + and re.fullmatch(r"[a-f0-9]{64}", digest) is not None + for path, digest in managed.items() + ): + raise ValueError("adoption lock managed hashes are invalid") + return managed + + +def content_digest(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def standard_adoption_records(active: list[TicketRecord]) -> list[TicketRecord]: + return [ + record for record in active + if record.intent is not None + and isinstance(record.intent.get("delivery"), dict) + and "standardAdoption" in record.intent["delivery"] + ] + + +def load_standard_adoption_evidence( + root: Path, + base: str, + adoption: dict[str, Any], +) -> tuple[ + dict[str, str], + dict[str, str], + dict[str, str], + dict[str, str], + bool, + dict[str, str], + dict[str, str], + dict[str, tuple[str, str]], +]: + base_package_content = git_revision_file(root, base, ".governance/package-manifest.json") + base_lock_content = git_revision_file(root, base, ".governance/manifest.lock.json") + head_package_path = safe_repo_path(root, ".governance/package-manifest.json") + head_lock_path = safe_repo_path(root, ".governance/manifest.lock.json") + if not head_package_path.is_file() or not head_lock_path.is_file(): + raise ValueError("head package manifest or lock is missing") + initial = adoption["fromRevision"] is None + if initial: + if base_package_content is not None: + raise ValueError("initial adoption base already contains a package manifest") + base_strategies: dict[str, str] = {} + base_hashes: dict[str, str] = {} + else: + if base_lock_content is None: + raise ValueError("upgrade base lock is missing") + if base_package_content is None: + base_strategies = {} + else: + base_strategies = package_strategies(base_package_content) + base_hashes = adoption_lock(base_lock_content, adoption["fromRevision"]) + head_strategies = package_strategies(head_package_path.read_bytes()) + head_hashes = adoption_lock(head_lock_path.read_bytes(), adoption["toRevision"]) + base_managed = {path for path, strategy in base_strategies.items() if strategy == "managed"} + head_managed = {path for path, strategy in head_strategies.items() if strategy == "managed"} + legacy_base = not base_strategies or (set(base_hashes) <= set(base_strategies)) + if ( + frozenset(base_hashes) not in {frozenset(base_strategies), frozenset(base_managed)} + and not legacy_base + ): + raise ValueError("base package targets and lock targets differ") + if set(head_hashes) != head_managed: + raise ValueError("package targets and lock targets differ") + takeovers = { + item["path"]: item["baseDigest"] + for item in adoption.get("managedTargetTakeovers", []) + } + restorations = { + item["path"]: item["baseDigest"] + for item in adoption.get("managedTargetRestorations", []) + } + transitions = { + item["path"]: (item["baseDigest"], item["headDigest"]) + for item in adoption.get("targetOwnedTransitions", []) + } + return ( + base_strategies, + head_strategies, + base_hashes, + head_hashes, + initial, + takeovers, + restorations, + transitions, + ) + + +def verify_managed_base(raw_path, base_content, base_strategies, base_hashes, initial, + takeovers, restorations, consumed_takeovers, consumed_restorations) -> bool: + if raw_path in base_hashes: + if base_strategies.get(raw_path) != "managed" and raw_path != ".governance/manifest.json": + raise ValueError(f"managed strategy continuity differs: {raw_path}") + if base_content is None: + if restorations.get(raw_path) != base_hashes[raw_path]: + raise ValueError( + f"base managed target is absent without matching restoration digest: {raw_path}" + ) + consumed_restorations.add(raw_path) + elif content_digest(base_content) != base_hashes[raw_path]: + observed_digest = content_digest(base_content) + if takeovers.get(raw_path) != observed_digest: + raise ValueError( + f"base managed hash differs without matching takeover digest: {raw_path}" + ) + consumed_takeovers.add(raw_path) + elif base_content is not None: + if initial: + # Installing the standard does not erase target ownership. + # A replaced path remains an ordinary implementation change. + return False + observed_digest = content_digest(base_content) + if takeovers.get(raw_path) != observed_digest: + raise ValueError( + f"new managed target already existed at base without matching takeover digest: {raw_path}" + ) + consumed_takeovers.add(raw_path) + return True + + +def verify_adoption_transitions(root, base, changed, base_strategies, head_strategies, transitions, exempt) -> None: + registry = adoption_binding_registry(root) + if transitions and registry is None: + raise ValueError("target-owned transitions require a valid managed adoption-binding registry") + target_patterns = registry[1] if registry is not None else [] + for raw_path, (base_digest, head_digest) in transitions.items(): + if raw_path not in changed: + raise ValueError(f"target-owned transition declaration was not consumed: {raw_path}") + if raw_path in base_strategies or raw_path in head_strategies: + raise ValueError(f"target-owned transition overlaps a package target: {raw_path}") + if not matches(raw_path, target_patterns): + raise ValueError(f"target-owned transition path is not allowlisted: {raw_path}") + base_content = git_revision_file(root, base, raw_path) + head_path = safe_repo_path(root, raw_path) + if base_content is None or not head_path.is_file(): + raise ValueError(f"target-owned transition must preserve an existing file: {raw_path}") + if content_digest(base_content) != base_digest: + raise ValueError(f"target-owned transition base hash differs: {raw_path}") + if content_digest(head_path.read_bytes()) != head_digest: + raise ValueError(f"target-owned transition head hash differs: {raw_path}") + exempt.add(raw_path) + + +def verify_changed_managed_paths( + root: Path, + base: str, + changed: list[str], + base_strategies: dict[str, str], + head_strategies: dict[str, str], + base_hashes: dict[str, str], + head_hashes: dict[str, str], + initial: bool, + takeovers: dict[str, str], + restorations: dict[str, str], + transitions: dict[str, tuple[str, str]], +) -> set[str]: + exempt: set[str] = set() + consumed_takeovers: set[str] = set() + consumed_restorations: set[str] = set() + for raw_path in changed: + if head_strategies.get(raw_path) != "managed": + continue + head_path = safe_repo_path(root, raw_path) + if not head_path.is_file() or content_digest(head_path.read_bytes()) != head_hashes[raw_path]: + raise ValueError(f"head managed hash differs: {raw_path}") + base_content = git_revision_file(root, base, raw_path) + if not verify_managed_base( + raw_path, base_content, base_strategies, base_hashes, initial, + takeovers, restorations, consumed_takeovers, consumed_restorations, + ): + continue + exempt.add(raw_path) + unused_takeovers = sorted(set(takeovers) - consumed_takeovers) + if unused_takeovers: + raise ValueError(f"managed target takeover declarations were not consumed: {', '.join(unused_takeovers)}") + unused_restorations = sorted(set(restorations) - consumed_restorations) + if unused_restorations: + raise ValueError( + "managed target restoration declarations were not consumed: " + + ", ".join(unused_restorations) + ) + verify_adoption_transitions(root, base, changed, base_strategies, head_strategies, transitions, exempt) + if not exempt: + raise ValueError("no changed managed payload was verified") + return exempt + + +def atomic_standard_adoption_paths( + root: Path, + base: str | None, + changed: list[str], + active: list[TicketRecord], + report: Report, +) -> set[str]: + records = standard_adoption_records(active) + if not records: + return set() + evidence_paths = [".governance/manifest.lock.json", ".governance/package-manifest.json"] + if len(records) != 1: + report.add( + "GOV-SYNC-001", + "Atomic standard adoption must resolve to exactly one active ticket.", + "Keep one approved adoption ticket active and serialize every other adoption.", + [rel(root, record.directory / "intent.json") for record in records], + ) + return set() + record = records[0] + assert record.intent is not None + adoption = record.intent["delivery"]["standardAdoption"] + error = standard_adoption_error(adoption) + if error or base is None or ".governance/manifest.lock.json" not in changed: + report.add( + "GOV-SYNC-001", + f"Atomic standard adoption preconditions are invalid: {error or 'base and changed lock are required'}.", + "Declare null-to-SHA bootstrap or distinct immutable upgrade revisions, compare against the approved Git base and regenerate the complete lock through Goal.", + evidence_paths, + ) + return set() + try: + evidence = load_standard_adoption_evidence(root, base, adoption) + verified_paths = verify_changed_managed_paths(root, base, changed, *evidence) + # The lock is verified input to the atomic adoption proof, but is not + # itself a package-managed payload. Keep it in the same one-ticket + # ownership slice instead of forcing a carrier-only companion ticket. + return verified_paths | {".governance/manifest.lock.json"} + except (OSError, TypeError, ValueError, KeyError, json.JSONDecodeError) as error: + report.add( + "GOV-SYNC-001", + f"Atomic standard adoption is inconsistent: {error}.", + "Restore the base, install the complete published managed set through Goal and regenerate its lock before review.", + evidence_paths, + {"ticket": record.directory.name, "base": base}, + ) + return set() + + +def change_scoped_records(root, active, changed, governance_patterns, adoption_paths): + changed_active = [ + record for record in active + if any(path.startswith(f"{rel(root, record.directory).rstrip('/')}/") for path in changed) + ] + if changed_active: + active = changed_active + implementation = [ + path for path in changed + if not matches(path, governance_patterns) and path not in adoption_paths + ] + if not changed_active and len(active) > 1 and implementation: + path_active = [record for record in active if ticket_owns_implementation(record, implementation)] + if len(path_active) == 1: + active = path_active + return active, implementation + + +def verified_import_paths(report: Report) -> set[str]: + """Only immutable paths proven against a pinned grant are history context.""" + return {path for proof in report.snapshot_migrations.values() + for path in proof["importedPaths"]} + + +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, + adoption_paths: set[str], + report: Report, + active_records: list[TicketRecord] | None = None, +) -> str | None: + governance_patterns = manifest["governancePaths"] + config = manifest["ticket"] + active = active_records if active_records is not None else active_ticket_records(root, config, records, report) + repair_changed = [path for path in changed if path not in verified_import_paths(report)] + active, implementation = change_scoped_records(root, active, repair_changed, governance_patterns, adoption_paths) + migrations = [record for record in active if record.directory.name in report.snapshot_migrations] + if not implementation and not migrations: + if changed and not adoption_paths: + report.add( + "GOV-MATERIAL-001", + "Changed paths contain only ticket tracking carriers.", + "Add a material source, test, configuration, standard or requested documentation change; if no material delta exists, emit an external no-change receipt without a commit or pull request.", + changed, + {"trackingCarriers": governance_patterns}, + ) + return None + repository = manifest.get("repository") + if repository and repository["mode"] == "monorepo": + outside_roots = [ + path for path in implementation + if not matches(path, repository["componentRoots"]) + ] + if outside_roots: + report.add( + "GOV-SCOPE-001", + "Monorepo implementation paths fall outside declared component roots.", + "Move the change under repository.componentRoots or update the manifest in a separately governed adoption.", + outside_roots, + {"componentRoots": repository["componentRoots"]}, + ) + # Import-only delivery is still material and requires the ordinary current + # ticket, chronology, bounded intent and independent exact-head approval. + selected = (migrations[0] if not implementation and len(migrations) == 1 else + 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: + 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]: + findings = payload["findings"] + rules = {} + results = [] + for item in findings: + rules[item["code"]] = { + "id": item["code"], + "shortDescription": {"text": item["message"]}, + "help": {"text": item["remediation"]}, + } + result: dict[str, Any] = { + "ruleId": item["code"], + "level": "error" if item["severity"] == "error" else "warning", + "message": {"text": item["message"]}, + } + if item["paths"]: + result["locations"] = [{ + "physicalLocation": {"artifactLocation": {"uri": item["paths"][0]}}, + }] + results.append(result) + return { + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "new-project-governance", "version": RUNTIME_VERSION, "rules": [rules[key] for key in sorted(rules)]}}, + "results": results, + }], + } + + +def render_text(payload: dict[str, Any]) -> str: + lines = [] + for item in payload["findings"]: + paths = f" [{', '.join(item['paths'])}]" if item["paths"] else "" + lines.append(f"{item['code']} {item['severity'].upper()}: {item['message']}{paths}") + lines.append(f" remediation: {item['remediation']}") + summary = payload["summary"] + code = "GOV-PASS" if payload["status"] == "passed" else "GOV-FAIL" + lines.append(f"{code}: {payload['status']} ({summary['errors']} errors, {summary['warnings']} warnings)") + if payload.get("cached"): + lines.append("Preflight cache: HIT (deterministic result reused)") + if "timings" in payload and payload["timings"]: + lines.append("Phase timings:") + for phase, duration in sorted(payload["timings"].items()): + lines.append(f" - {phase}: {duration:.4f}s" if isinstance(duration, (int, float)) else f" - {phase}: {duration}") + return "\n".join(lines) + "\n" + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".") + parser.add_argument("--manifest", default=".governance/manifest.json") + parser.add_argument("--lock", default=None) + parser.add_argument("--stack-profiles", default=None) + parser.add_argument( + "--work-classification", + default=".governance/work-classification.dsl.json", + ) + parser.add_argument("--base") + parser.add_argument("--infer-ticket-base", action="store_true", + help="On a ticket/NNN-* branch without --base, validate from its merge-base with origin/") + parser.add_argument("--head", default="HEAD") + parser.add_argument("--changed-file", action="append", default=[]) + parser.add_argument("--actor", choices=["agent", "human", "ci"], default="agent") + parser.add_argument("--trusted-human-change", action="store_true") + 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("--ticket-database", help="Local primary-checkout project.sqlite; forbidden for CI/approval enforcement") + parser.add_argument("--ticket-snapshot", help="Externally acquired ticket snapshot outside Git checkouts") + parser.add_argument("--ticket-snapshot-sha256", help="Independent protected snapshot digest") + parser.add_argument("--migration-authorization", help="External migration grant selected by protected policy") + parser.add_argument("--migration-authorization-sha256", help="Independently protected grant digest") + parser.add_argument("--migration-branch", help="Authenticated PR head branch, including detached jobs") + parser.add_argument("--resolved-ticket-output") + parser.add_argument("--elapsed-minutes", type=int) + parser.add_argument("--timing", action="store_true", help="Report phase execution timings") + parser.add_argument("--no-cache", action="store_true", help="Bypass reading and writing governance preflight cache") + parser.add_argument("--cache-file", help="Custom path to governance preflight cache JSON") + parser.add_argument("--format", choices=["text", "json", "sarif"], default="text") + parser.add_argument("--output") + return parser.parse_args(argv) + + +def load_manifest(root: Path, raw_path: str, report: Report) -> dict[str, Any] | None: + try: + 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.") + 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 + + +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, + base: str | None, + report: Report, +) -> list[str]: + try: + return changed_paths(root, 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": base, "head": args.head}, + ) + return [] + + +def published_adoption_validation_base(root: Path, delivery: dict[str, Any], head: str) -> str | None: + """Bound implicit adoption validation without changing ticket authority. + + A historical IN_PROGRESS adoption may remain in a fresh published checkout + without its external terminal receipt. Validate the latest integration's + diff there, not every delivery since that adoption. Require origin evidence; + a local target branch alone does not establish published state. + """ + try: + target_ref = f"refs/remotes/origin/{delivery['targetBranch']}" + target = git_output(root, ["rev-parse", "--verify", f"{target_ref}^{{commit}}"]).decode().strip() + if git_output(root, ["rev-parse", "--verify", f"{head}^{{commit}}"]).decode().strip() != target: + return None + parent = git_output(root, ["rev-parse", "--verify", f"{target}^1"]).decode().strip() + if is_published_integration(root, target, parent): + return parent + except (subprocess.CalledProcessError, FileNotFoundError): + pass + return None + + +def resolve_validation_base( + supplied_base: str | None, + root: Path, + records: list[TicketRecord], + config: dict[str, Any], + head: str = "HEAD", + active_records: list[TicketRecord] | None = None, +) -> str | None: + if supplied_base is not None: + return supplied_base + active = active_records if active_records is not None else active_ticket_records(root, config, records) + adoption_records = standard_adoption_records(active) + # If an adoption ticket's delivery is already landed on its target branch, + # its prose status remains active only because terminal receipts were not recorded. + # Exclude landed adoptions so subsequent tickets do not have their validation base poisoned. + unlanded_adoptions = [] + for record in adoption_records: + if record.intent is None: + continue + delivery = record.intent.get("delivery") + if not isinstance(delivery, dict): + continue + ticket_dir = getattr(record, "directory", None) + target_branch = delivery.get("targetBranch") + if ticket_dir is not None and target_branch: + target_ref = f"refs/remotes/origin/{target_branch}" + try: + if delivery_landed(root, ticket_dir, target_ref): + continue + except Exception: + pass + unlanded_adoptions.append(record) + deliveries = [record.intent["delivery"] for record in unlanded_adoptions if record.intent is not None] + if not deliveries: + return None + # Fresh published clones retain historical adoption prose but not external + # terminal receipts. Establish the latest integration's range before its + # changed-ticket filter decides ownership; do not declare tickets terminal. + if len({delivery["targetBranch"] for delivery in deliveries}) == 1: + published_base = published_adoption_validation_base(root, deliveries[0], head) + if published_base is not None: + return published_base + if len(deliveries) != 1: + return None + return deliveries[0]["acceptedBaseSha"] + + +def check_change_lease(root: Path, report: Report) -> None: + candidates = [ + root / "scripts" / "change_lease_check.py", + root / ".governance" / "change_lease_check.py", + ] + checker = next((path for path in candidates if path.is_file()), None) + if checker is None: + return + spec = importlib.util.spec_from_file_location("new_project_change_lease_check", checker) + if spec is None or spec.loader is None: + report.add( + "GOV-CHANGE-LEASE-001", "Could not load the managed change-lease checker.", + "Restore the managed checker from the pinned new-project package.", [rel(root, checker)], + ) + return + module = importlib.util.module_from_spec(spec) + try: + previous_bytecode_policy = sys.dont_write_bytecode + try: + sys.dont_write_bytecode = True + spec.loader.exec_module(module) + finally: + sys.dont_write_bytecode = previous_bytecode_policy + findings = module.validate_repository(root) + except Exception as error: + report.add( + "GOV-CHANGE-LEASE-001", f"Change-lease validation failed closed: {error}", + "Repair the managed checker or lease evidence before continuing.", [rel(root, checker)], + ) + return + for item in findings: + report.add( + item["code"], item["message"], + "Read the authoritative lease and follow error/GOV-CHANGE-LEASE.md.", + evidence=item.get("evidence", {}), + ) + + +def timed_step(report: Report, name: str, func, *args, **kwargs): + if not report.timing: + return func(*args, **kwargs) + start = time.perf_counter() + try: + return func(*args, **kwargs) + finally: + report.record_timing(name, time.perf_counter() - start) + + +def compute_git_dirty_digest(root: Path) -> str: + """Computes a deterministic digest of uncommitted worktree changes.""" + try: + proc = subprocess.run( + ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], + cwd=root, + capture_output=True, + check=True, + ) + except (subprocess.SubprocessError, OSError): + return "git-status-failed" + + raw = proc.stdout + if not raw: + return "clean" + + fields = iter(raw.split(b"\0")) + dirty_files: list[str] = [] + for field in fields: + if not field: + continue + path_bytes = field[3:] + try: + rel_path = path_bytes.decode("utf-8") + except UnicodeDecodeError: + rel_path = path_bytes.decode("utf-8", errors="replace") + dirty_files.append(rel_path) + if field[:2] in (b"R ", b"C "): + try: + dest = next(fields).decode("utf-8", errors="replace") + dirty_files.append(dest) + except StopIteration: + pass + + file_hashes: dict[str, str] = {} + for rel_path in sorted(set(dirty_files)): + if rel_path.startswith(".subactor/cache/"): + continue + p = root / rel_path + if p.is_symlink(): + try: + target = os.readlink(p) + file_hashes[rel_path] = f"symlink:{target}" + except OSError: + file_hashes[rel_path] = "symlink:error" + elif p.is_file(): + try: + hasher = hashlib.sha256() + with p.open("rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + hasher.update(chunk) + file_hashes[rel_path] = hasher.hexdigest() + except OSError: + file_hashes[rel_path] = "read-error" + else: + file_hashes[rel_path] = "absent" + + hasher = hashlib.sha256() + hasher.update(raw) + for k in sorted(file_hashes.keys()): + hasher.update(f"\n{k}:{file_hashes[k]}".encode("utf-8")) + return hasher.hexdigest() + + +def file_sha256_or_none(path: Path | None) -> str | None: + if path is None or not path.is_file(): + return None + try: + hasher = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + hasher.update(chunk) + return hasher.hexdigest() + except OSError: + return "read-error" + + +def infer_ticket_branch_base(root: Path, manifest_path: Path | None, head: str, report: Report) -> str | None: + """Bind a bare local run on a ticket branch to the range its PR will be validated on. + + Without a base, only adoption tickets infer one, so an ordinary ticket's + committed implementation is invisible and a missing delivery contract + passes. A branch that cannot be bound fails closed instead. + """ + branch = subprocess.run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], + cwd=root, capture_output=True, text=True, check=False).stdout.strip() + match = re.fullmatch(r"ticket/([0-9]+)-[^/]+", branch) + if match is None: + return None + try: + targets = load_json(manifest_path)["delivery"]["targetBranches"] if manifest_path else None + except (OSError, ValueError, KeyError, TypeError): + targets = None + try: + declared = load_json(root / f"project/ticket-{match.group(1)}/intent.json")["delivery"]["targetBranch"] + except (OSError, ValueError, KeyError, TypeError): + declared = None + if isinstance(targets, list) and len(targets) == 1: + target = targets[0] + else: + # Several or no policy targets: only the ticket's own declaration may choose. + target = declared if not isinstance(targets, list) or declared in targets else None + target = target if branch_name(target) else None + remote = f"refs/remotes/origin/{target}" + base = (subprocess.run(["git", "merge-base", head, remote], cwd=root, + capture_output=True, text=True, check=False).stdout.strip() if target else "") + if re.fullmatch(r"[0-9a-f]{40,64}", base) is None: + report.add( + "GOV-BASE-001", + f"Ticket branch {branch} has no validation base: " + + (f"{remote} is missing or unrelated to {head}." if target + else "the manifest names no single delivery target branch for it."), + f"Run git fetch origin {target or ''} or pass --base explicitly; " + "a bare run never validates a ticket branch without a range.", + [], {"branch": branch, "targetBranch": target}, + ) + return None + print(f"governance-check: --base {base} (merge-base of {branch} and origin/{target})", file=sys.stderr) + return base + + +def git_rev_parse(root: Path, ref: str) -> str: + try: + proc = subprocess.run( + ["git", "rev-parse", "--verify", ref], + cwd=root, + capture_output=True, + text=True, + check=True, + ) + return proc.stdout.strip() + except (subprocess.SubprocessError, OSError): + return f"unresolved:{ref}" + + +def compute_preflight_cache_key( + root: Path, + args: argparse.Namespace, + manifest_path: Path, + lock_path: Path | None, + profiles_path: Path | None, + work_classification_path: Path | None, +) -> str | None: + # A Git dirty digest cannot bind external ticket input, including SQLite WAL. + # Let the ordinary reader recheck its current contents and snapshot pins. + if not args.base or any((args.ticket_database, args.ticket_snapshot, args.ticket_snapshot_sha256)): + return None + try: + storage = git_output(root, ["config", "--local", "--get", "new-project.ticketStorage"]).decode().strip() + except subprocess.CalledProcessError as error: + if error.returncode != 1: + return None + storage = "files" + except (OSError, UnicodeError): + return None + if storage != "files": + return None + + head_sha = git_rev_parse(root, args.head or "HEAD") + base_sha = git_rev_parse(root, args.base) + if head_sha.startswith("unresolved:") or base_sha.startswith("unresolved:"): + return None + dirty_digest = compute_git_dirty_digest(root) + if dirty_digest == "git-status-failed": + return None + + manifest_sha = file_sha256_or_none(manifest_path) + if manifest_sha is None: + return None + + key_payload = { + "runtime_version": RUNTIME_VERSION, + "head_sha": head_sha, + "base_sha": base_sha, + "dirty_digest": dirty_digest, + "manifest_sha": manifest_sha, + "lock_sha": file_sha256_or_none(lock_path), + "profiles_sha": file_sha256_or_none(profiles_path), + "work_classification_sha": file_sha256_or_none(work_classification_path), + "actor": args.actor, + "trusted_human_change": bool(args.trusted_human_change), + "changed_files": sorted(args.changed_file), + "approval_source": args.approval_source, + "approved_ticket": args.approved_ticket, + "approval_evidence": args.approval_evidence, + "expected_repository": args.expected_repository, + "expected_pull_request": args.expected_pull_request, + "expected_head": args.expected_head, + "ticket_database": args.ticket_database, + "ticket_snapshot": args.ticket_snapshot, + "ticket_snapshot_sha256": args.ticket_snapshot_sha256, + "migration_authorization": args.migration_authorization, + "migration_authorization_sha256": args.migration_authorization_sha256, + "migration_branch": args.migration_branch, + "elapsed_minutes": args.elapsed_minutes, + } + return hashlib.sha256(json.dumps(key_payload, sort_keys=True).encode("utf-8")).hexdigest() + + +def resolve_cache_path(root: Path, custom_path: str | None) -> Path: + if custom_path: + p = Path(custom_path) + return p if p.is_absolute() else (root / p) + return root / ".subactor" / "cache" / "governance-preflight.json" + + +def is_preflight_cache_allowed(args: argparse.Namespace) -> bool: + if args.no_cache: + return False + if args.actor == "ci" or args.enforce_approval: + return False + if os.environ.get("CI") == "true" or os.environ.get("GITHUB_ACTIONS") == "true": + return False + return True + + +def load_preflight_cache(cache_path: Path, cache_key: str) -> dict[str, Any] | None: + if not cache_path.is_file(): + return None + try: + with cache_path.open("r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict) or data.get("schema") != "new-project.governance-preflight-cache/v1": + return None + entries = data.get("entries") + if isinstance(entries, dict) and cache_key in entries: + entry = entries[cache_key] + if isinstance(entry, dict) and "payload" in entry: + return entry + except (OSError, ValueError, json.JSONDecodeError): + return None + return None + + +def save_preflight_cache( + cache_path: Path, + cache_key: str, + payload: dict[str, Any], + selected_ticket: str | None, +) -> None: + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + data: dict[str, Any] = {"schema": "new-project.governance-preflight-cache/v1", "entries": {}} + if cache_path.is_file(): + try: + with cache_path.open("r", encoding="utf-8") as f: + existing = json.load(f) + if isinstance(existing, dict) and existing.get("schema") == "new-project.governance-preflight-cache/v1": + if isinstance(existing.get("entries"), dict): + data["entries"] = existing["entries"] + except Exception: + pass + if len(data["entries"]) >= 50: + keys_to_remove = list(data["entries"].keys())[: len(data["entries"]) - 49] + for k in keys_to_remove: + data["entries"].pop(k, None) + entry_payload = dict(payload) + entry_payload.pop("cached", None) + data["entries"][cache_key] = { + "payload": entry_payload, + "selected_ticket": selected_ticket, + } + tmp_path = cache_path.with_suffix(".tmp") + with tmp_path.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2, sort_keys=True) + f.write("\n") + tmp_path.replace(cache_path) + except OSError: + pass + + +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) + repository_policy = selected_policy(manifest) + if repository_policy.get("tickets", {}).get("backend") == "planfile": + projected = load_repository_policy_ticket(root, manifest, report) + records = [projected] if projected is not None else [] + directories = [] + active = records[:] + else: + try: + records = load_external_ticket_records(args, root, manifest["ticket"]) + except Exception: + report.add("GOV-INTENT-002", "Ticket input is missing, invalid or untrusted for this validation mode.", + "Use initialized local SQLite for local checks, or an independently acquired exact-subject snapshot and digest for CI.") + return None + if records is None: + directories = ticket_directories(root, manifest["ticket"]) + records = load_ticket_records(directories, manifest["ticket"]) + else: + directories = [record.directory for record in records] + active = timed_step(report, "active_ticket_records", active_ticket_records, root, manifest["ticket"], records, report) + base = timed_step(report, "resolve_validation_base", resolve_validation_base, args.base, root, records, manifest["ticket"], args.head, active) + changed = timed_step(report, "resolve_changed_paths", resolve_changed_paths, args, root, base, report) + historical_tickets, migration_repairs = prepare_snapshot_migrations(args, root, records, base, changed, report) + if historical_tickets: + # This candidate's imported metadata is historical evidence, not a live + # reservation. The external controller still owns leases and closure. + records = [record for record in records if record.directory.name not in historical_tickets] + directories = [record.directory for record in records] + active = [record for record in active if record.directory.name not in historical_tickets] + changed = sorted(set(changed) | migration_repairs) + changed_active = [ + record for record in active + if any(path.startswith(f"{rel(root, record.directory).rstrip('/')}/") for path in changed) + ] + adoption_paths = atomic_standard_adoption_paths( + root, base, changed, changed_active or active, report, + ) + timed_step(report, "load_work_classification", load_work_classification, root, report, args.work_classification) + timed_step(report, "check_lock", check_lock, root, lock_path, manifest, report) + timed_step(report, "check_policy_dsl", check_policy_dsl, root, report) + timed_step(report, "check_host_merge_policy", check_host_merge_policy, root, report) + timed_step(report, "check_required_checks_declaration", check_required_checks_declaration, root, report) + timed_step(report, "check_agent_hosts", check_agent_hosts, root, args.actor, report) + timed_step(report, "check_required_files", check_required_files, root, manifest, report) + timed_step(report, "check_version_parity", check_version_parity, root, manifest, report) + timed_step(report, "check_domain_contracts", check_domain_contracts, root, manifest, report) + timed_step(report, "check_docker_image_references", check_docker_image_references, root, manifest, report) + timed_step(report, "check_stacks", check_stacks, root, manifest, profiles_path, report) + timed_step(report, "check_ticket_content", check_ticket_content, root, directories, active, manifest["ticket"], report, records) + repair_changed = [path for path in changed if path not in verified_import_paths(report)] + timed_step(report, "check_coordination", check_coordination, root, manifest, records, repair_changed, adoption_paths, report, active) + timed_step(report, "check_change_lease", check_change_lease, root, report) + timed_step(report, "check_changed_content", check_changed_content, root, changed, args.actor, args.trusted_human_change, report) + return timed_step( + report, "check_change_gate", check_change_gate, + root, manifest, records, changed, 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, adoption_paths, report, active, + ) + + +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: + t_start = time.perf_counter() + args = parse_args(argv or sys.argv[1:]) + root = Path(args.root).resolve() + report = Report(root, timing=args.timing) + + cache_allowed = is_preflight_cache_allowed(args) + cache_path = resolve_cache_path(root, args.cache_file) + cache_key: str | None = None + + manifest_path: Path | None = None + lock_path: Path | None = None + profiles_path: Path | None = None + work_class_path: Path | None = None + + try: + manifest_path = safe_repo_path(root, args.manifest) + except ValueError: + pass + try: + lock_path = safe_repo_path(root, args.lock) if args.lock else None + except ValueError: + pass + try: + profiles_path = safe_repo_path(root, args.stack_profiles) if args.stack_profiles else None + except ValueError: + pass + try: + work_class_path = safe_repo_path(root, args.work_classification) if args.work_classification else None + except ValueError: + pass + + if args.infer_ticket_base and args.base is None and not args.changed_file: + args.base = infer_ticket_branch_base(root, manifest_path, args.head, report) + + if cache_allowed and manifest_path and manifest_path.is_file(): + cache_key = compute_preflight_cache_key( + root, args, manifest_path, lock_path, profiles_path, work_class_path + ) + if cache_key: + cached_entry = load_preflight_cache(cache_path, cache_key) + if cached_entry is not None: + payload = cached_entry["payload"] + selected_ticket = cached_entry.get("selected_ticket") + payload["cached"] = True + if args.timing: + timings = dict(payload.get("timings", {})) + timings["preflight_cache"] = round(time.perf_counter() - t_start, 4) + payload["timings"] = timings + 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) + current = report.payload() + if current["findings"]: + payload["findings"].extend(current["findings"]) + for field in ("errors", "warnings", "findings"): + payload["summary"][field] += current["summary"][field] + payload["status"] = "failed" if payload["summary"]["errors"] else "passed" + write_report(output_path, formatted_report(payload, args.format)) + return 0 if payload["summary"]["errors"] == 0 else 1 + + 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() + if args.timing: + report.record_timing("total", time.perf_counter() - t_start) + payload["timings"] = report.timings + + if cache_allowed and cache_key and report.errors == 0: + save_preflight_cache(cache_path, cache_key, payload, selected_ticket) + + write_report(output_path, formatted_report(payload, args.format)) + return 0 if report.errors == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/idle_takeover.py b/.governance/idle_takeover.py new file mode 100755 index 0000000..80fa3db --- /dev/null +++ b/.governance/idle_takeover.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +"""Idle ticket takeover and the autonomy state board (new-project.idle-takeover/v1). + +A ticket whose writer shows no activity for ``coordination.idleTakeover.idleMinutes`` +may be taken over by another agent through the change-lease CAS: +``expire`` -> ``release`` -> a fresh ``claimed`` lease with a higher fencing token. +The policy lives only in the governance manifest; without it takeover is disabled. + +Activity is the newest of the lease heartbeat, the branch commit, any dirty file +in the ticket worktree and the ticket's continuity events. A live process whose +working directory is inside the worktree always counts as activity. Dirty work is +stored as a content-addressed snapshot before ownership moves; the worktree itself +is never reset. Frozen publication phases, integrated tickets, worktrees outside +the wellmanifest/worktrees v5 layout and invalid leases are reported, never taken +over. + +Every observation rewrites the board ``.subactor/cache/autonomy-board.{json,md}`` +in the primary checkout: owner, idle time, state, the canonical worktree and lease +paths and links to the project, branch, README, pull request and issue list, so +the next agent can pick work without reconstructing the state from chat. +""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import io +import json +import os +import re +import subprocess +import sys +import tarfile +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +try: # Exclusive lease transition lock: fcntl on POSIX, msvcrt on Windows. + import fcntl +except ImportError: # pragma: no cover - Windows + fcntl = None + import msvcrt + +SCHEMA = "new-project.idle-takeover/v1" +BOARD_SCHEMA = "new-project.autonomy-board/v1" +# Runtime namespaces this module owns; all lie under the root-ignored +# wellmanifest/worktrees operational directories (cache, snapshots, receipts). +BOARD_DIR = ".subactor/cache" +SNAPSHOT_DIR = ".subactor/snapshots/idle-takeover" +RECEIPT_DIR = ".subactor/receipts/idle-takeover" +CONTINUITY_MANIFEST = ".subactor/manifest.json" +POLICY_MANIFESTS = (".governance/manifest.json", "governance/manifest.hub.json") +POLICY_FIELDS = ("enabled", "idleMinutes", "leaseMinutes") +# Bounded inputs: a snapshot larger than this needs a human handoff, and only the +# newest part of the append-only continuity stream is read. +MAX_SNAPSHOT_BYTES = 64 * 1024 * 1024 +MAX_CONTINUITY_BYTES = 8 * 1024 * 1024 +# Layout fallbacks for pinned lease contracts older than new-project 0.20.120, +# which do not export the wellmanifest/worktrees v5 roots yet. +LEGACY_LAYOUT = {"WORKTREES_ROOT": ".worktrees", "LEASE_ROOT": ".subactor/leases", "MAX_LEASE_BYTES": 65536} +TICKET_RE = re.compile(r"ticket-[0-9]{3,}") +IDENTITY_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:@/-]{0,159}") +# Forge URL shapes for the board; the host comes from the origin remote. +FORGE_PATHS = {"branch": "tree/{branch}", "readme": "blob/{branch}/project/{ticket}/README.md", + "pull": "pull/{number}", "pullSearch": "pulls?q=head%3A{branch}", + "pulls": "pulls", "issues": "issues"} + + +def pinned_module(primary: Path | None, name: str): + """Load the repository's own pinned .governance copy; fall back to the sibling script.""" + pinned = primary / ".governance" / name if primary else None + source = pinned if pinned and pinned.is_file() else Path(__file__).resolve().parent / name + module_name = f"idle_takeover_{Path(name).stem}" + spec = importlib.util.spec_from_file_location(module_name, source) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def lease_runtime(primary: Path | None = None): + """Validate leases with the repository's own pinned contract.""" + return pinned_module(primary, "change_lease_check.py") + + +def contract(runtime) -> dict[str, Any]: + """Layout and phase vocabulary come from the pinned change-lease contract (SSOT).""" + layout = {key: getattr(runtime, key, value) for key, value in LEGACY_LAYOUT.items()} + frozen, expirable = set(runtime.FROZEN_PHASES), set(runtime.TRANSITIONS["expire"]) + released_from = set(runtime.TRANSITIONS["release"]) + terminal = set(runtime.PHASES) - expirable - released_from | (released_from - {"expired"} - expirable) + return dict(layout, frozen=frozen, terminal=terminal, + takeover=(expirable - frozen) | (released_from - terminal)) + + +def git(root: Path, *args: str, check: bool = True) -> str: + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + result = subprocess.run(["git", "-C", str(root), *args], env=env, capture_output=True, text=True, check=check) + return result.stdout.strip() + + +def utc(value: str | None) -> datetime | None: + if not isinstance(value, str): + return None + try: + moment = datetime.fromisoformat(value.replace("Z", "+00:00").replace("z", "+00:00")) + except ValueError: + return None + return moment if moment.utcoffset() is not None else None + + +def stamp(moment: datetime) -> str: + return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + except (OSError, json.JSONDecodeError): + return None + + +def primary_checkout(root: Path) -> Path: + """wellmanifest/worktrees rule 1: the primary is the first registered worktree, never a linked one.""" + rows = git(root, "worktree", "list", "--porcelain").split("\n\n") + return Path(rows[0].splitlines()[0].split(" ", 1)[1]).resolve() + + +def manifest_policy(primary: Path) -> dict[str, Any]: + """The governance manifest is the only source of the policy; absent or malformed means disabled. + + Validity is decided by the pinned governance_check.idle_takeover_policy_valid, + the same closed check the manifest gate applies (exact bool, bounded positive + integers, lease longer than the idle threshold).""" + valid = getattr(pinned_module(primary, "governance_check.py"), "idle_takeover_policy_valid", None) + for relative in POLICY_MANIFESTS: + manifest = read_json(primary / relative) + if manifest is None: + continue + configured = manifest.get("coordination", {}).get("idleTakeover") if isinstance(manifest, dict) else None + if valid is not None and valid(configured) and configured["enabled"] is True: + return {key: configured[key] for key in POLICY_FIELDS} | {"source": relative} + return {"enabled": False, "idleMinutes": None, "leaseMinutes": None, "source": relative} + return {"enabled": False, "idleMinutes": None, "leaseMinutes": None, "source": None} + + +def worktrees(primary: Path) -> dict[str, dict[str, str]]: + found = {} + for row in git(primary, "worktree", "list", "--porcelain").split("\n\n"): + fields = dict(line.split(" ", 1) for line in row.splitlines() if " " in line) + branch = fields.get("branch", "") + if "/ticket/" in branch: + found[branch] = {"path": fields["worktree"], "head": fields.get("HEAD", "")} + return found + + +def leases(primary: Path, layout: dict[str, Any]) -> list[tuple[Path, dict[str, Any]]]: + directory = primary / layout["LEASE_ROOT"] + loaded = [] + for path in sorted(directory.glob("*.json")) if directory.is_dir() else []: + try: + if path.is_symlink() or not path.is_file() or path.stat().st_size > layout["MAX_LEASE_BYTES"]: + raise ValueError("Lease must be a bounded regular file") + value = json.loads(path.read_text(encoding="utf-8")) + loaded.append((path, value if isinstance(value, dict) else {})) + except (OSError, ValueError): + loaded.append((path, {})) + return loaded + + +def canonical_layout(primary: Path, lease_path: Path, lease: dict[str, Any], tree: dict[str, str] | None, + layout: dict[str, Any]) -> dict[str, Any]: + """wellmanifest/worktrees v5 rules 2, 3 and 7: path, lease stem and branch name one delivery unit.""" + worktree_id = str(lease.get("worktreeId", "")) + ticket, _, slug = worktree_id.partition("--") + expected_path = primary / layout["WORKTREES_ROOT"] / worktree_id + expected_lease = primary / layout["LEASE_ROOT"] / f"{worktree_id}.json" + expected_branch = f"refs/heads/ticket/{ticket.removeprefix('ticket-')}-{slug}" + problems = [] + if not TICKET_RE.fullmatch(ticket) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", slug): + problems.append("worktreeId") + if lease_path != expected_lease: + problems.append("leasePath") + if lease.get("branchRef") != expected_branch or lease.get("ticketId") != ticket: + problems.append("branch") + if tree and Path(tree["path"]) != expected_path: + problems.append("worktreePath") + if any(part.is_symlink() for part in (expected_path, *expected_path.parents) if part != part.parent and part.is_relative_to(primary)): + problems.append("symlink") + return {"worktreePath": str(expected_path), "leasePath": str(expected_lease), + "branch": expected_branch.removeprefix("refs/heads/"), "problems": problems} + + +def continuity_times(primary: Path) -> dict[str, datetime]: + """Newest checkpoint per ticket from the stream the continuity manifest declares.""" + manifest = read_json(primary / CONTINUITY_MANIFEST) + relative = manifest.get("continuity", {}).get("eventStreamPath") if isinstance(manifest, dict) else None + latest: dict[str, datetime] = {} + stream = primary / relative if isinstance(relative, str) else None + if stream is None or not stream.is_file(): + return latest + with stream.open("rb") as handle: + size = handle.seek(0, os.SEEK_END) + handle.seek(max(0, size - MAX_CONTINUITY_BYTES)) + lines = handle.read().decode("utf-8", "replace").splitlines() + for line in lines: + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + checkpoint = event.get("checkpoint") if isinstance(event, dict) else None + if isinstance(checkpoint, dict): + ticket, moment = checkpoint.get("ticket"), utc(checkpoint.get("recordedAt")) + if isinstance(ticket, str) and moment and moment > latest.get(ticket, moment - timedelta(seconds=1)): + latest[ticket] = moment + return latest + + +def live_processes(path: Path) -> list[int]: + """Processes whose cwd is inside the worktree (Linux /proc only; elsewhere none are observed).""" + proc, own, pids = Path("/proc"), {os.getpid(), os.getppid()}, [] + if not proc.is_dir(): + return pids + target = str(path.resolve()) + for entry in proc.iterdir(): + if not entry.name.isdigit() or int(entry.name) in own: + continue + try: + cwd = os.readlink(entry / "cwd") + except OSError: + continue + if cwd == target or cwd.startswith(target + os.sep): + pids.append(int(entry.name)) + return sorted(pids) + + +def dirty_paths(worktree: Path) -> list[str]: + raw = subprocess.run(["git", "-C", str(worktree), "status", "--porcelain", "-z", "--untracked-files=all"], + capture_output=True, check=True).stdout.decode("utf-8", "surrogateescape") + paths, entries = [], raw.split("\0") + index = 0 + while index < len(entries): + entry = entries[index] + if len(entry) > 3: + paths.append(entry[3:]) + # Porcelain -z: a rename or copy in either status column is followed + # by its source path as a separate record. + if set(entry[:2]) & {"R", "C"}: + index += 1 + if index < len(entries) and entries[index]: + paths.append(entries[index]) + index += 1 + return sorted(set(paths)) + + +def integrated(primary: Path, head: str, target: str) -> bool: + """A ticket HEAD already contained in the target branch needs release, not takeover.""" + if not re.fullmatch(r"[0-9a-f]{40}", head or ""): + return False + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + # Any target ref that contains HEAD is enough; a stale remote ref must not hide a local merge. + return any( + git(primary, "rev-parse", "-q", "--verify", ref, check=False) + and subprocess.run(["git", "-C", str(primary), "merge-base", "--is-ancestor", head, ref], env=env).returncode == 0 + for ref in (f"refs/remotes/origin/{target}", f"refs/heads/{target}")) + + +def web_url(primary: Path) -> str | None: + remote = git(primary, "remote", "get-url", "origin", check=False) + match = re.fullmatch(r"(?:https?://|ssh://)?(?:[^@/]+@)?([^/:]+)[:/](.+?)(?:\.git)?/?", remote) + return f"https://{match[1]}/{match[2]}" if match else None + + +def links(base: str | None, branch: str | None, ticket: str, pull_request: Any) -> dict[str, str | None]: + if not base: + return {"project": None, "branch": None, "readme": None, "pullRequest": None, "issues": None} + name = branch.removeprefix("refs/heads/") if branch else None + number = re.search(r"([0-9]+)$", str(pull_request)) if pull_request else None + url = lambda key, **values: f"{base}/{FORGE_PATHS[key].format(**values)}" # noqa: E731 + return { + "project": base, + "branch": url("branch", branch=name) if name else None, + "readme": url("readme", branch=name, ticket=ticket) if name else None, + "pullRequest": url("pull", number=number[1]) if number else (url("pullSearch", branch=name) if name else None), + "issues": url("issues"), + } + + +def classify(lease, phase, invalid, layout_record, tree, dirty, pids, last, now, policy, rules, primary) -> str: + if invalid: + return "INVALID_LEASE" + if layout_record["problems"]: + return "NONCANONICAL_LAYOUT" + if pids or (last and policy["enabled"] and now - last < timedelta(minutes=policy["idleMinutes"])): + return "ACTIVE" + if not policy["enabled"]: + return "POLICY_DISABLED" + if phase in rules["frozen"] or lease.get("publicationFrozen"): + return "IDLE_FROZEN" + if not tree: + return "IDLE_NO_WORKTREE" + if not dirty and integrated(primary, tree["head"], str(lease.get("targetBranch", "main"))): + return "IDLE_INTEGRATED" + if phase not in rules["takeover"]: + return "IDLE_NOT_TAKEABLE" + return "IDLE_TAKEOVER_ELIGIBLE" + + +def observe(root: Path, now: datetime | None = None, runtime=None) -> dict[str, Any]: + now = now or datetime.now(timezone.utc) + primary = primary_checkout(root) + runtime = runtime or lease_runtime(primary) + rules, policy = contract(runtime), manifest_policy(primary) + trees, continuity, base = worktrees(primary), continuity_times(primary), web_url(primary) + entries = [] + for path, lease in leases(primary, rules): + ticket = str(lease.get("ticketId", path.stem.split("--")[0])) + phase = str(lease.get("phase", "unknown")) + if phase in rules["terminal"]: + continue + tree = trees.get(str(lease.get("branchRef"))) + record = canonical_layout(primary, path, lease, tree, rules) + activity = {"heartbeat": utc(lease.get("heartbeatAt")), "continuity": continuity.get(ticket)} + dirty, pids = [], [] + if tree and Path(tree["path"]).is_dir(): + worktree = Path(tree["path"]) + activity["commit"] = utc(git(worktree, "log", "-1", "--format=%cI", check=False)) + dirty = dirty_paths(worktree) + times = [(worktree / item).stat().st_mtime for item in dirty if (worktree / item).is_file()] + activity["dirtyFile"] = datetime.fromtimestamp(max(times), timezone.utc) if times else None + pids = live_processes(worktree) + known = {key: value for key, value in activity.items() if value} + source, last = max(known.items(), key=lambda item: item[1]) if known else (None, None) + invalid = bool(runtime.validate_lease(lease)) if lease else True + entries.append({ + "ticket": ticket, + "state": classify(lease, phase, invalid, record, tree, dirty, pids, last, now, policy, rules, primary), + "ownerActor": lease.get("ownerActor"), "ownerSession": lease.get("ownerSession"), "phase": phase, + "leaseRevision": lease.get("leaseRevision"), "fencingToken": lease.get("fencingToken"), + "leasePath": str(path), "worktree": tree["path"] if tree else None, + "canonicalWorktree": record["worktreePath"], "layoutProblems": record["problems"], + "branchRef": lease.get("branchRef"), "lastActivityAt": stamp(last) if last else None, + "lastActivitySource": source, "idleMinutes": int((now - last).total_seconds() // 60) if last else None, + "livePids": pids, "dirtyPaths": dirty[:50], "dirtyCount": len(dirty), + "links": links(base, lease.get("branchRef"), ticket, lease.get("pullRequest")), + }) + entries.sort(key=lambda item: (item["state"], item["idleMinutes"] if item["idleMinutes"] is not None else -1)) + return {"schema": BOARD_SCHEMA, "observedAt": stamp(now), "primaryCheckout": str(primary), + "worktreesRoot": str(primary / rules["WORKTREES_ROOT"]), "leaseRoot": str(primary / rules["LEASE_ROOT"]), + "project": base, "issues": f"{base}/{FORGE_PATHS['issues']}" if base else None, + "pullRequests": f"{base}/{FORGE_PATHS['pulls']}" if base else None, "policy": policy, + "grantsAuthority": False, "tickets": entries} + + +BACKGROUND_STATES = {"IDLE_NO_WORKTREE", "INVALID_LEASE", "IDLE_INTEGRATED", "NONCANONICAL_LAYOUT"} + + +def board_markdown(board: dict[str, Any]) -> str: + def link(label: str, url: str | None) -> str: + return f"[{label}]({url})" if url else "-" + primary = Path(board["primaryCheckout"]) + def local(path: str | None) -> str: + return f"`{Path(path).relative_to(primary)}`" if path and Path(path).is_relative_to(primary) else (f"`{path}`" if path else "-") + policy = board["policy"] + rule = (f"Idle takeover after {policy['idleMinutes']} min (from `{policy['source']}`)." if policy["enabled"] + else f"Idle takeover disabled (`coordination.idleTakeover` in `{policy['source']}`).") + lines = [ + "# Autonomy board", "", + f"Observed {board['observedAt']} in `{board['primaryCheckout']}`. {rule} Advisory state; it grants no authority.", "", + f"Project: {link('repository', board['project'])} · {link('pull requests', board['pullRequests'])} · {link('issues', board['issues'])}", "", + f"Worktrees: `{board['worktreesRoot']}` · leases: `{board['leaseRoot']}` (wellmanifest/worktrees v5). " + "Work only in the worktree a lease names; never create one by hand.", "", + "| Ticket | State | Owner | Phase | Idle | Last activity | Dirty | Worktree | Links |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + background = [item for item in board["tickets"] if item["state"] in BACKGROUND_STATES] + for item in (item for item in board["tickets"] if item["state"] not in BACKGROUND_STATES): + idle = "-" if item["idleMinutes"] is None else f"{item['idleMinutes']} min" + refs = " · ".join(link(name, item["links"][key]) for name, key in + (("branch", "branch"), ("README", "readme"), ("PR", "pullRequest"))) + lines.append(f"| {item['ticket']} | {item['state']} | {item['ownerActor']} | {item['phase']} | {idle} " + f"| {item['lastActivitySource'] or '-'} | {item['dirtyCount']} | {local(item['worktree'])} | {refs} |") + if background: + lines += ["", f"{len(background)} further leases are integrated, have no ticket worktree, break the worktree " + "layout or fail the pinned lease contract: " + + ", ".join(f"{item['ticket']} ({item['state']}, {item['ownerActor']})" for item in background[:40]) + + (" …" if len(background) > 40 else "") + ". Reconcile them; they are never taken over automatically."] + eligible = [item for item in board["tickets"] if item["state"] == "IDLE_TAKEOVER_ELIGIBLE"] + if eligible: + lines += ["", "## Takeover candidates", ""] + lines += [f"- `{item['ticket']}` in {local(item['worktree'])}: `python3 .governance/idle_takeover.py takeover " + f"--ticket {item['ticket']} --actor --session `" for item in eligible] + return "\n".join(lines) + "\n" + + +def write_atomic(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_bytes(data) + os.replace(temporary, path) + + +@contextmanager +def lease_lock(lease_path: Path): + """Serialize every idle-takeover transition of one lease; a held lock refuses, never waits.""" + lock = lease_path.with_name(lease_path.name + ".lock") + descriptor = os.open(lock, os.O_RDWR | os.O_CREAT, 0o600) + try: + try: + if fcntl: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + else: # pragma: no cover - Windows + msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1) + except OSError as error: + raise PermissionError("Another transition holds this lease; re-observe.") from error + yield + finally: + os.close(descriptor) + + +def write_board(board: dict[str, Any]) -> Path: + directory = Path(board["primaryCheckout"]) / BOARD_DIR + write_atomic(directory / "autonomy-board.json", (json.dumps(board, indent=2, sort_keys=True) + "\n").encode()) + write_atomic(directory / "autonomy-board.md", board_markdown(board).encode()) + return directory / "autonomy-board.md" + + +def file_identity(path: Path) -> tuple[int, int, int, int]: + status = path.stat() + return status.st_dev, status.st_ino, status.st_size, status.st_mtime_ns + + +def snapshot(primary: Path, entry: dict[str, Any]) -> dict[str, Any] | None: + """Content-addressed tar of the dirty files plus the tracked diff; the worktree stays untouched.""" + if not entry["dirtyCount"]: + return None + worktree = Path(entry["worktree"]) + buffer, total = io.BytesIO(), 0 + with tarfile.open(fileobj=buffer, mode="w", format=tarfile.PAX_FORMAT) as archive: + def add(name: str, data: bytes) -> None: + info = tarfile.TarInfo(name) + info.size, info.mtime, info.mode = len(data), 0, 0o644 + archive.addfile(info, io.BytesIO(data)) + diff = subprocess.run(["git", "-C", str(worktree), "diff", "--binary", "HEAD"], capture_output=True, check=True).stdout + add("tracked.diff", diff) + for item in dirty_paths(worktree): + path = worktree / item + if path.is_file() and not path.is_symlink(): + data = path.read_bytes() + total += len(data) + if total > MAX_SNAPSHOT_BYTES: + raise ValueError("Dirty work exceeds the bounded snapshot size; hand off instead.") + add("files/" + item, data) + data = buffer.getvalue() + digest = hashlib.sha256(data).hexdigest() + target = primary / SNAPSHOT_DIR / entry["ticket"] / f"{digest}.tar" + if not target.exists(): + write_atomic(target, data) + return {"ref": f"snapshot:idle-takeover/{entry['ticket']}/{digest}", "sha256": digest, + "path": str(target), "paths": entry["dirtyPaths"], "headSha": git(worktree, "rev-parse", "HEAD")} + + +def takeover(root: Path, ticket: str, actor: str, session: str, now: datetime | None = None) -> dict[str, Any]: + if not TICKET_RE.fullmatch(ticket) or not IDENTITY_RE.fullmatch(actor) or not IDENTITY_RE.fullmatch(session): + raise ValueError("Exact ticket, actor and session identities are required.") + runtime = lease_runtime(primary_checkout(root)) + now = now or datetime.now(timezone.utc) + board = observe(root, now, runtime) + write_board(board) + entry = next((item for item in board["tickets"] if item["ticket"] == ticket), None) + if entry is None: + raise PermissionError(f"{ticket} is NO_LIVE_LEASE; idle takeover needs IDLE_TAKEOVER_ELIGIBLE.") + with lease_lock(Path(entry["leasePath"])): + # Decide again under the lock: the first observation only located the lease. + return locked_takeover(root, ticket, actor, session, now, runtime) + + +def locked_takeover(root: Path, ticket: str, actor: str, session: str, now: datetime, runtime) -> dict[str, Any]: + board = observe(root, now, runtime) + entry = next((item for item in board["tickets"] if item["ticket"] == ticket), None) + if entry is None or entry["state"] != "IDLE_TAKEOVER_ELIGIBLE": + state = entry["state"] if entry else "NO_LIVE_LEASE" + raise PermissionError(f"{ticket} is {state}; idle takeover needs IDLE_TAKEOVER_ELIGIBLE.") + if entry["ownerActor"] == actor and entry["ownerSession"] == session: + raise PermissionError(f"{ticket} is already held by this session; heartbeat instead of taking over.") + primary, lease_path = Path(board["primaryCheckout"]), Path(entry["leasePath"]) + original, identity = lease_path.read_bytes(), file_identity(lease_path) + lease = json.loads(original) + snap = snapshot(primary, entry) + authority = (f"policy:{SCHEMA};idleMinutes={entry['idleMinutes']};limit={board['policy']['idleMinutes']};" + f"lastActivity={entry['lastActivityAt']}" + (f";snapshot={snap['sha256']}" if snap else "")) + receipts, current, sequence = [], dict(lease), int(lease.get("eventSequence", 0)) + for action in (["expire"] if current["phase"] != "expired" else []) + ["release"]: + request = {"schema": runtime.REQUEST_SCHEMA, "requestId": f"idle-takeover-{action}-{stamp(now)}", + "leaseId": current["leaseId"], "action": action, "expectedRevision": current["leaseRevision"], + "expectedFencingToken": current["fencingToken"], "expectedPhase": current["phase"], + "requestedBy": actor, "idempotencyKey": f"{ticket}:{current['fencingToken']}:{action}", + "targetHeadSha": None, "replacementReceiptRef": None, "authorityRef": authority, + "requestedAt": stamp(now)} + receipt, errors = runtime.evaluate_transition(current, request) + if errors: + raise PermissionError(f"{action} rejected: {errors[0]['code']} {errors[0]['message']}") + receipts.append(receipt) + sequence += 1 + current.update(phase=receipt["phaseAfter"], leaseRevision=receipt["leaseRevision"], + fencingToken=receipt["fencingToken"], previousReceiptRef=receipt["receiptRef"], eventSequence=sequence) + worktree = Path(entry["worktree"]) + claimed = dict(current, ownerActor=actor, ownerSession=session, phase="claimed", + leaseRevision=current["leaseRevision"] + 1, fencingToken=current["fencingToken"] + 1, + issuedAt=stamp(now), heartbeatAt=stamp(now), + expiresAt=stamp(now + timedelta(minutes=board["policy"]["leaseMinutes"])), + headSha=git(worktree, "rev-parse", "HEAD"), eventSequence=sequence + 1) + errors = runtime.validate_lease(claimed) + if errors: + raise ValueError(f"Claimed lease is invalid: {errors[0]['message']}") + # wellmanifest/worktrees rules 9-10: a machine-readable result names the exact + # ticket, branch, worktree and lease; the agent takes its paths only from here. + record = {"schema": SCHEMA, "ticket": ticket, "takenOverAt": stamp(now), + "branch": entry["branchRef"].removeprefix("refs/heads/"), "worktreePath": str(worktree), + "leasePath": str(lease_path), + "writerEnvironment": {"NEW_PROJECT_WRITER_ACTOR": actor, "NEW_PROJECT_WRITER_SESSION": session, + "NEW_PROJECT_LEASE_REVISION": str(claimed["leaseRevision"]), + "NEW_PROJECT_FENCING_TOKEN": str(claimed["fencingToken"])}, + "previousOwner": {"actor": lease["ownerActor"], "session": lease["ownerSession"], + "leaseRevision": lease["leaseRevision"], "fencingToken": lease["fencingToken"]}, + "newOwner": {"actor": actor, "session": session, "leaseRevision": claimed["leaseRevision"], + "fencingToken": claimed["fencingToken"]}, + "idle": {"minutes": entry["idleMinutes"], "lastActivityAt": entry["lastActivityAt"], + "source": entry["lastActivitySource"]}, + "authorityRef": authority, "snapshot": snap, "receipts": receipts} + # Compare-and-swap under the lease lock, immediately before the receipt and + # the replacement; the fencing token rejects any writer that bypassed the lock. + if lease_path.read_bytes() != original or file_identity(lease_path) != identity: + raise PermissionError("Lease changed during takeover (compare-and-swap lost); re-observe.") + trace = primary / RECEIPT_DIR / f"{ticket}.jsonl" + trace.parent.mkdir(parents=True, exist_ok=True) + with trace.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, sort_keys=True) + "\n") + write_atomic(lease_path, (json.dumps(claimed, indent=2, sort_keys=True) + "\n").encode()) + record["board"] = str(write_board(observe(root, now, runtime))) + return record + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--root", type=Path, default=Path(".")) + commands = parser.add_subparsers(dest="command", required=True) + show = commands.add_parser("observe", help="classify ticket leases and rewrite the autonomy board") + show.add_argument("--format", choices=("json", "md"), default="md") + show.add_argument("--no-write", action="store_true", help="print only; do not rewrite the board") + take = commands.add_parser("takeover", help="take over an IDLE_TAKEOVER_ELIGIBLE ticket") + take.add_argument("--ticket", required=True) + take.add_argument("--actor", required=True) + take.add_argument("--session", required=True) + args = parser.parse_args(argv) + try: + if args.command == "observe": + board = observe(args.root) + if not args.no_write: + write_board(board) + sys.stdout.write(json.dumps(board, indent=2) + "\n" if args.format == "json" else board_markdown(board)) + return 0 + record = takeover(args.root, args.ticket, args.actor, args.session) + sys.stdout.write(json.dumps(record, indent=2, sort_keys=True) + "\n") + return 0 + except PermissionError as error: + sys.stderr.write(f"GOV-IDLE-TAKEOVER-001: {error}\n") + return 1 + except (OSError, ValueError, subprocess.CalledProcessError) as error: + sys.stderr.write(f"GOV-IDLE-TAKEOVER-002: {error}\n") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.governance/intent.schema.json b/.governance/intent.schema.json new file mode 100644 index 0000000..cfa6a09 --- /dev/null +++ b/.governance/intent.schema.json @@ -0,0 +1,384 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/intent.schema.json", + "title": "new-project ticket intent", + "type": "object", + "additionalProperties": false, + "required": ["schema", "ticket", "summary", "workstream", "allowedPaths", "forbiddenPaths", "stacks", "dependsOn", "conflictsWith", "integrationTicket"], + "properties": { + "schema": { "enum": ["new-project.intent/v2", "new-project.intent/v3"] }, + "ticket": { "type": "string", "pattern": "^ticket-[0-9]{3,}$" }, + "summary": { "type": "string", "minLength": 1 }, + "workstream": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "allowedPaths": { "type": "array", "items": { "$ref": "#/$defs/glob" }, "minItems": 1, "uniqueItems": true }, + "forbiddenPaths": { "type": "array", "items": { "$ref": "#/$defs/glob" }, "uniqueItems": true }, + "stacks": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "dependsOn": { "type": "array", "items": { "type": "string", "pattern": "^ticket-[0-9]{3,}$" }, "uniqueItems": true }, + "conflictsWith": { "type": "array", "items": { "type": "string", "pattern": "^ticket-[0-9]{3,}$" }, "uniqueItems": true }, + "integrationTicket": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "pattern": "^ticket-[0-9]{3,}$" } + ] + }, + "classification": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "priority", "origin"], + "properties": { + "kind": { "enum": ["BUG", "FEATURE", "SERVICE"] }, + "priority": { "enum": ["P0", "P1", "P2", "P3"] }, + "origin": { "enum": ["regression", "requested", "health"] } + } + }, + "physicalChanges": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "description": "Required when a changed path matches an adopter physical contract (P-PHYS-001). One entry per exact contract/property/signal; the runtime rejects repeated claims even when before, after or acceptance differ. Property none records an edit that keeps physical meaning and must be the only entry for that contract.", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["contract", "property", "signal", "before", "after", "acceptance"], + "properties": { + "contract": { "type": "string", "minLength": 1 }, + "property": { "enum": ["pin-assignment", "active-level", "pull", "capability-set", "unit-scale", "limit-range", "timing"] }, + "signal": { "type": "string", "minLength": 1 }, + "before": { "type": "string", "minLength": 1 }, + "after": { "type": "string", "minLength": 1 }, + "acceptance": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["contract", "property", "rationale"], + "properties": { + "contract": { "type": "string", "minLength": 1 }, + "property": { "const": "none" }, + "rationale": { "type": "string", "minLength": 1 } + } + } + ] + } + }, + "placement": { + "type": "object", + "additionalProperties": false, + "required": ["home", "shape"], + "description": "Optional HOME vs ADOPT. Fill for SERVICE/FEATURE that create or place a repository. Missing is valid so existing tickets still parse; invalid values reject. runtime_service must not HOME wellmanifest.", + "properties": { + "home": { + "enum": ["wellmanifest", "subactor", "semcod", "paxlet-com", "dockuri"], + "description": "Org that owns the repository. Not the same as adopt." + }, + "runtimeOwner": { + "enum": ["wellmanifest", "subactor", "semcod", "paxlet-com", "dockuri"], + "description": "Org that runs the CLI/daemon. Omit to inherit home. wellmanifest does not host product daemons." + }, + "shape": { + "enum": ["domain_pack", "runtime_service", "both"] + }, + "adopt": { + "type": "array", + "description": "wellmanifest packs to follow. ADOPT is not HOME.", + "items": { + "type": "string", + "pattern": "^wellmanifest/[a-z0-9][a-z0-9-]*$" + }, + "uniqueItems": true + } + } + }, + "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", "M", "L"] }, + "estimatedMinutes": { "type": "integer", "minimum": 1, "maximum": 240 }, + "snapshotMigration": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "repository", + "baseSha", + "sourceSha", + "sourceTree", + "inventorySha256", + "authorizationRef" + ], + "properties": { + "schema": { + "const": "new-project.snapshot-migration/v1" + }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "baseSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "sourceSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "sourceTree": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "inventorySha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "authorizationRef": { + "type": "string", + "pattern": "^authorization:[A-Za-z0-9._/-]{1,200}$" + } + } + }, + "standardAdoption": { + "type": "object", + "additionalProperties": false, + "required": ["sourceRepository", "fromRevision", "toRevision"], + "properties": { + "sourceRepository": { "const": "wellmanifest/new-project" }, + "fromRevision": { + "oneOf": [ + { "$ref": "#/$defs/sha" }, + { "type": "null" } + ] + }, + "toRevision": { "$ref": "#/$defs/sha" }, + "managedTargetTakeovers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "baseDigest"], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?![A-Za-z]:[/\\\\])(?!.*(?:^|/)\\.\\.(?:/|$))[^*?\\[]+$" + }, + "baseDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + }, + "managedTargetRestorations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "baseDigest"], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?![A-Za-z]:[/\\\\])(?!.*(?:^|/)\\.\\.(?:/|$))[^*?\\[]+$" + }, + "baseDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + }, + "targetOwnedTransitions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "baseDigest", "headDigest"], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?![A-Za-z]:[/\\\\])(?!.*(?:^|/)\\.\\.(?:/|$))[^*?\\[]+$" + }, + "baseDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "headDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } + } + }, + "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": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "component", "description"], + "properties": { + "kind": { "enum": ["component-local-state", "schema-migration", "cross-component-migration", "ownership-transfer", "unknown"] }, + "component": { "type": "string", "minLength": 1 }, + "description": { "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 } + } + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { "schema": { "const": "new-project.intent/v3" } }, + "required": ["schema"] + }, + "then": { "required": ["classification"] } + }, + { + "if": { + "properties": { "schema": { "const": "new-project.intent/v2" } }, + "required": ["schema"] + }, + "then": { "not": { "required": ["classification"] } } + } + ], + "$defs": { + "glob": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" }, + "branch": { "type": "string", "minLength": 1, "pattern": "^(?!@?$)(?!HEAD$)(?![-/])(?![\\s\\S]*(?:\\.\\.|//|@\\{|[ ~^:?*\\[\\\\\\x00-\\x1f\\x7f]))(?![\\s\\S]*(?:^|/)\\.)(?![\\s\\S]*(?:^|/)[^/]*\\.lock(?:/|$))(?![\\s\\S]*[/.]$)[\\s\\S]+$" }, + "sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" } + } +} diff --git a/.governance/lock.schema.json b/.governance/lock.schema.json new file mode 100644 index 0000000..4f41584 --- /dev/null +++ b/.governance/lock.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/lock.schema.json", + "title": "new-project governance lock", + "type": "object", + "additionalProperties": false, + "required": ["schema", "standard", "managedFiles"], + "properties": { + "schema": { "const": "new-project.lock/v1" }, + "standard": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "sourceRepository", "sourceRevision", "publicationStatus"], + "properties": { + "id": { "const": "wellmanifest/new-project" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "sourceRepository": { "const": "wellmanifest/new-project" }, + "sourceRevision": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "publicationStatus": { + "enum": ["published", "unpublished-test"] + } + } + }, + "managedFiles": { + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" }, + "additionalProperties": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + } +} diff --git a/.governance/managed-copies.json b/.governance/managed-copies.json new file mode 100644 index 0000000..6846100 --- /dev/null +++ b/.governance/managed-copies.json @@ -0,0 +1,5 @@ +{ + ".governance/docs/BRANCH_INTENT_RECONCILIATION.md": "d17163b016e1ad8cf97408d528bf5a6443e931ea694e97191abc7c1e50586c3b", + ".governance/docs/LOCAL_CI_PUBLICATION.md": "001ba246f985ab10d0e4bb70ca18b45a9f3c80b5af70718ec30f6dfecd54bff8", + ".governance/docs/SNAPSHOT_MIGRATION.md": "c73fe642f48e6b1d54b36c9a5f55d2e2a93f8405389533733c26907bd69d6012" +} diff --git a/.governance/manifest.base.json b/.governance/manifest.base.json new file mode 100644 index 0000000..78b1560 --- /dev/null +++ b/.governance/manifest.base.json @@ -0,0 +1,175 @@ +{ + "approvalEvidence": { + "requiredBindings": [ + "repository", + "pullRequest", + "headSha", + "ticket", + "actor" + ], + "reviewVerificationMethod": "github-api-allowlist", + "schema": "new-project.approval-evidence/v1", + "signedAttestationPredicateType": "https://wellmanifest.com/attestations/validator/v1" + }, + "coordination": { + "idleTakeover": { + "enabled": true, + "idleMinutes": 15, + "leaseMinutes": 120 + }, + "integration": { + "workstream": "integration" + }, + "maxActiveTicketsPerWorkstream": 8, + "mode": "workstreams", + "rejectActiveScopeOverlap": true + }, + "delivery": { + "allowedComplexityClasses": [ + "XS", + "S", + "M", + "L" + ], + "checkpointMinutes": 30, + "dependencyManifestPaths": [ + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "pyproject.toml", + "uv.lock", + "poetry.lock", + "go.mod", + "go.sum", + "Cargo.toml", + "Cargo.lock", + "pom.xml" + ], + "maxActiveMinutes": 120, + "maxAffectedComponents": 5, + "maxImplementationFiles": 15, + "maxPublicInterfaceChanges": 3, + "maxRuntimeDependencies": 3, + "profiles": { + "L": { + "maxAffectedComponents": 5, + "maxImplementationFiles": 15, + "maxPublicInterfaceChanges": 3, + "maxRuntimeDependencies": 3 + }, + "M": { + "maxAffectedComponents": 3, + "maxImplementationFiles": 9, + "maxPublicInterfaceChanges": 2, + "maxRuntimeDependencies": 2 + }, + "S": { + "maxAffectedComponents": 2, + "maxImplementationFiles": 5, + "maxPublicInterfaceChanges": 1, + "maxRuntimeDependencies": 1 + }, + "XS": { + "maxAffectedComponents": 1, + "maxImplementationFiles": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + } + }, + "targetBranches": [ + "main" + ] + }, + "docker": { + "composeFiles": [ + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml", + "compose.e2e.yml" + ], + "dockerfiles": [ + "Dockerfile", + "Dockerfile.e2e" + ] + }, + "domainContracts": { + "mode": "none" + }, + "governancePaths": [ + "TODO.md", + "project/TICKETS.md", + "project/ticket-*/**", + ".governance/standard-adoption.json", + "config/artifact-registry.json" + ], + "repository": { + "componentRoots": [], + "mode": "standalone" + }, + "repositoryPolicy": { + "delivery": { + "checkout": "linked-worktree", + "mode": "ticket-branch", + "targetBranches": [ + "main" + ] + }, + "enforcement": "enforce", + "profile": "ticket-worktree", + "tickets": { + "backend": "files", + "idPattern": "^ticket-[0-9]{3,}$", + "referenceRequired": true + } + }, + "requiredFiles": [ + "README.md", + "VERSION", + "CHANGELOG.md", + "TODO.md", + "AGENTS.md", + "project/TICKETS.md", + "project/new-ticket.sh", + "project/readme.sh" + ], + "schema": "new-project.governance/v2", + "stacks": [], + "standard": { + "id": "wellmanifest/new-project", + "version": "0.20.131" + }, + "ticket": { + "activeStatuses": [ + "IN_PROGRESS" + ], + "closedStatuses": [ + "DONE", + "CANCELLED" + ], + "directoryPattern": "^ticket-[0-9]{3,}$", + "implementationStates": [ + "EDIT", + "VALIDATION", + "PUBLICATION" + ], + "intentFile": "intent.json", + "nonActiveStatuses": [ + "BACKLOG", + "PLAN", + "BLOCKED" + ], + "requiredAgentFiles": [], + "requiredFiles": [ + "README.md", + "intent.json" + ], + "root": "project" + }, + "trustedApprovalSources": [ + "github-review", + "github-app-review", + "signed-attestation" + ] +} diff --git a/.governance/manifest.json b/.governance/manifest.json new file mode 100644 index 0000000..bb6ab55 --- /dev/null +++ b/.governance/manifest.json @@ -0,0 +1,291 @@ +{ + "$schema": "./manifest.schema.json", + "approvalEvidence": { + "requiredBindings": [ + "repository", + "pullRequest", + "headSha", + "ticket", + "actor" + ], + "reviewVerificationMethod": "github-api-allowlist", + "schema": "new-project.approval-evidence/v1", + "signedAttestationPredicateType": "https://wellmanifest.com/attestations/validator/v1" + }, + "coordination": { + "idleTakeover": { + "enabled": true, + "idleMinutes": 15, + "leaseMinutes": 120 + }, + "integration": { + "requiredForPaths": [ + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "pyproject.toml", + "setup.py", + "setup.cfg", + "uv.lock", + "poetry.lock", + "go.mod", + "go.sum", + "Cargo.toml", + "Cargo.lock", + "pom.xml", + "operations/**", + "events/**", + "error/**", + "models/**", + "proto/**" + ], + "workstream": "integration" + }, + "maxActiveTicketsPerWorkstream": 8, + "mode": "workstreams", + "rejectActiveScopeOverlap": true, + "workstreams": { + "application": { + "ownedPaths": [ + "src/**", + "app/**", + "lib/**", + "packages/**", + "test/**", + "tests/**" + ] + }, + "governance": { + "ownedPaths": [ + ".aider.conf.yml", + ".benchmarks/**", + ".cursor/rules/**", + ".githooks/**", + ".github/copilot-instructions.md", + ".github/workflows/new-project-governance.yml", + ".gitignore", + ".governance/**", + ".redup/**", + ".reuse/**", + ".subactor/**", + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + "README.md", + "TODO.md", + "CHANGELOG.md", + ".env.example", + "goal.yaml", + "LICENSE*", + "project.sh", + "project.bat", + "project/**", + "scripts/install-agent-hosts.sh", + "scripts/runtime.sh", + "wellmanifest_governance.py", + "worktree-guard.yaml", + ".gitattributes", + ".github/workflows/new-project-branch-hygiene.yml" + ] + }, + "infrastructure": { + "ownedPaths": [ + "Dockerfile*", + "compose*.yml", + "compose*.yaml", + "infra/**", + ".github/workflows/**" + ] + }, + "integration": { + "ownedPaths": [ + "VERSION", + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "pyproject.toml", + "setup.py", + "setup.cfg", + "uv.lock", + "poetry.lock", + "go.mod", + "go.sum", + "Cargo.toml", + "Cargo.lock", + "pom.xml", + "docs/**", + "OVERVIEW.md", + "operations/**", + "events/**", + "error/**", + "models/**", + "proto/**" + ] + }, + "interfaces": { + "ownedPaths": [ + "api/**", + "sdk/**", + "clients/**" + ] + } + } + }, + "delivery": { + "allowedComplexityClasses": [ + "XS", + "S", + "M", + "L" + ], + "checkpointMinutes": 30, + "dependencyManifestPaths": [ + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "pyproject.toml", + "uv.lock", + "poetry.lock", + "go.mod", + "go.sum", + "Cargo.toml", + "Cargo.lock", + "pom.xml" + ], + "maxActiveMinutes": 120, + "maxAffectedComponents": 5, + "maxImplementationFiles": 15, + "maxPublicInterfaceChanges": 3, + "maxRuntimeDependencies": 3, + "profiles": { + "L": { + "maxAffectedComponents": 5, + "maxImplementationFiles": 15, + "maxPublicInterfaceChanges": 3, + "maxRuntimeDependencies": 3 + }, + "M": { + "maxAffectedComponents": 3, + "maxImplementationFiles": 9, + "maxPublicInterfaceChanges": 2, + "maxRuntimeDependencies": 2 + }, + "S": { + "maxAffectedComponents": 2, + "maxImplementationFiles": 5, + "maxPublicInterfaceChanges": 1, + "maxRuntimeDependencies": 1 + }, + "XS": { + "maxAffectedComponents": 1, + "maxImplementationFiles": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + } + }, + "publicInterfacePaths": [ + "api/**", + "sdk/**", + "src/public/**" + ], + "requiredForImplementation": false, + "targetBranches": [ + "main" + ] + }, + "docker": { + "composeFiles": [ + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml", + "compose.e2e.yml" + ], + "dockerfiles": [ + "Dockerfile", + "Dockerfile.e2e" + ], + "required": false + }, + "domainContracts": { + "mode": "none" + }, + "governancePaths": [ + "TODO.md", + "project/TICKETS.md", + "project/ticket-*/**", + ".governance/standard-adoption.json", + "config/artifact-registry.json" + ], + "repository": { + "componentRoots": [], + "mode": "standalone" + }, + "repositoryPolicy": { + "delivery": { + "checkout": "linked-worktree", + "mode": "ticket-branch", + "targetBranches": [ + "main" + ] + }, + "enforcement": "enforce", + "profile": "ticket-worktree", + "tickets": { + "backend": "files", + "idPattern": "^ticket-[0-9]{3,}$", + "referenceRequired": true + } + }, + "requiredFiles": [ + "README.md", + "VERSION", + "CHANGELOG.md", + "TODO.md", + "AGENTS.md", + "project/TICKETS.md", + "project/new-ticket.sh", + "project/readme.sh" + ], + "schema": "new-project.governance/v2", + "stacks": [], + "standard": { + "id": "wellmanifest/new-project", + "version": "0.20.131" + }, + "ticket": { + "activeStatuses": [ + "IN_PROGRESS" + ], + "closedStatuses": [ + "DONE", + "CANCELLED" + ], + "directoryPattern": "^ticket-[0-9]{3,}$", + "implementationStates": [ + "EDIT", + "VALIDATION", + "PUBLICATION" + ], + "intentFile": "intent.json", + "nonActiveStatuses": [ + "BACKLOG", + "PLAN", + "BLOCKED" + ], + "requiredAgentFiles": [], + "requiredFiles": [ + "README.md", + "intent.json" + ], + "root": "project" + }, + "trustedApprovalSources": [ + "github-review", + "github-app-review", + "signed-attestation" + ] +} diff --git a/.governance/manifest.lock.json b/.governance/manifest.lock.json new file mode 100644 index 0000000..47d0d68 --- /dev/null +++ b/.governance/manifest.lock.json @@ -0,0 +1,134 @@ +{ + "managedFiles": { + ".aider.conf.yml": "6de9c74f752e8a879419698531ac575a1f2a3b93efd38a257ac1c48682a26bdd", + ".cursor/rules/new-project-standard.mdc": "8884003b3a4470e4677ca5128645f466021432ceb3f018a9f1aa743210f4181d", + ".githooks/commit-msg": "df3e39f98bfebf971392a66b643163145872ff474d4d7cb01c6e05ed4ceb4199", + ".githooks/pre-commit": "647ad5c1fbadcd9b1858890e91d35bbe90ead0a726a32c66ef7b75a0637c2358", + ".githooks/pre-push": "7b2b9a485ce2a0b4586387ffab539da5bb363283f45b7a37d3bacc42c5565126", + ".github/copilot-instructions.md": "08330b67c95c3c175573fe1bec06c21a2f49e6c419164d04980d4f81b4efb8a8", + ".github/workflows/new-project-branch-hygiene.yml": "e51a2dd4b60e79b40a24023310bac50a86b595208eb9502956e7ac5754ba1f7f", + ".github/workflows/new-project-governance.yml": "297606a400885f0a0d131de6df8d6fd6870940bdb5ecdd7f2f746fe77da033b9", + ".governance/AGENT_DECISIONS.md": "bc511acb758aa7120d06f5f795eae820a02b868adcfef30377312f8c33dbb44a", + ".governance/adoption-bindings.json": "9a6015fde26226d4c55764c81c1ab8e8d42fcf64661533a71d90fcc488edaeb3", + ".governance/adoption-bindings.schema.json": "0fbad765ca67924b7b3340dcad09f262b62c979740a291da7dacd8e48d28241c", + ".governance/agent-hosts.json": "d03e7bd5ed2941311a7917e4b26fd8a5ef38e809611ed2648b0fd3f290dc4d4c", + ".governance/agent-hosts.schema.json": "4cda59d458f774490bada85d1c1b86167e71268cb1a2b8c9bd5126601ddfbb0b", + ".governance/agent_host_check.py": "efcb7c2ab6b3a7c6b31b30f1a7db035f2f94b378d8d3bd33e213b72f8df7faf3", + ".governance/approval-evidence.schema.json": "e0f79eb7bdb534ec17e1a94a56b06a371df14b6f6b9f34db6cf4f8ee059718d5", + ".governance/branch-intent-reconciliation.schema.json": "8d770fc7c81884844c3218cc1f18c11ed9c9f7851f2769e040eec676e8ea8006", + ".governance/branch_intent_reconciliation.py": "cf316043eaff77021183d64f5b6f8b291fb0c38b62a9bae7203845a8b35592ca", + ".governance/branch_lifecycle_check.py": "dc562dfad667b809e46aabd7beccf97b30852fa68ee92112f4a8836d8df9802e", + ".governance/change-evaluation.schema.json": "0ac0908b551d0d36ec7007f5b23b888b6d11376ea450aae68068f1b0faa9060b", + ".governance/change-lease.schema.json": "f9b8eabf4d66ced63fbb1c5d8ae733f88bcf64016c8f9cf101eb4a17533406c6", + ".governance/change_lease_check.py": "37efbbc4c4279226f28ac449812aee9cf78f905281cd8339a24d25131d427a6b", + ".governance/check_required_checks.py": "5d2ca54d56c01fda3021ed5c1b5e22f91ae64120447af424255510a0a4474c09", + ".governance/decision-record.schema.json": "9300b57ee9c1823adb1b2ddbf0eb86827b270c5ca030a91d69eefbf042e034a9", + ".governance/decision_record.py": "f5fda6d70f4173a0131c243a1893ffe7a8657a4aa3b675db8f69ccd411d95766", + ".governance/diagnostics.json": "b6781acba2a375ea0909cc339949d4a26e0f675f08a6e3a6874bc3fe99c4bac0", + ".governance/diagnostics.schema.json": "5c28e6a54319d106c234e63e1f65933533b5bfd5cdf6509a17b28362b56f86e1", + ".governance/docs/BRANCH_INTENT_RECONCILIATION.md": "d17163b016e1ad8cf97408d528bf5a6443e931ea694e97191abc7c1e50586c3b", + ".governance/docs/LOCAL_CI_PUBLICATION.md": "001ba246f985ab10d0e4bb70ca18b45a9f3c80b5af70718ec30f6dfecd54bff8", + ".governance/docs/SNAPSHOT_MIGRATION.md": "c73fe642f48e6b1d54b36c9a5f55d2e2a93f8405389533733c26907bd69d6012", + ".governance/error/GOV-AGENT-HOST.md": "abd836976efcbe048c6a04010ae02f88e3fe469cc3864fe6ae54acea82583a6a", + ".governance/error/GOV-APPROVAL.md": "3b508f23491f2ad93e115481699fab84ef3def844f7580736c29c8afafb1ec47", + ".governance/error/GOV-ARCHITECTURE-001.md": "33cabd81fbd1bede22a85b0f5c27897f56d8ec8bf3c43ae4ba37d8de145c9b90", + ".governance/error/GOV-CHANGE-LEASE.md": "778c9d7c09f8c53c2b6a7e29692ffae2150ffcac1dcda0c1c11f51346af5edb8", + ".governance/error/GOV-HOST-MERGE.md": "59d4bf531c9d2d461fe23f8c8a3a6c5423a6051c0d6d5e1c9f35fb60f628fba3", + ".governance/error/GOV-IDLE-TAKEOVER.md": "4c28ccb8ebadf8c94f6237a92e0d887c7f4d64cb04f14fcf7dc199a2fc379c31", + ".governance/error/GOV-INTENT.md": "c078852b4ebb868a0f49463e33df4db410e30989856c5cc5e2d3108289024f75", + ".governance/error/GOV-PACKAGING.md": "a5ca878344c86fe9c0f4017aa8220d4dfd493870f5ef8bbdd6980d566f04efc6", + ".governance/error/GOV-PHYS.md": "f10991e7cf35097a1328ed110c86039156e79b650facac8588cc6136b6d82e24", + ".governance/error/GOV-PUSH.md": "d2f685b927ee637d3ac4a0c6a9d80582808627b542452e87f01df5d81400d620", + ".governance/error/GOV-REMEDIATION-INTENT.md": "ff0f41bf5112a1808738554b51c13a24ce97f7842304f1aca33e9f9846d73aa3", + ".governance/error/GOV-SNAPSHOT-MIGRATION.md": "b82f44800ce8470d7769cef8b4b4c46a70279cc1fffdbd6592cfbe43741b3cb4", + ".governance/error/GOV-STANDARD-UPDATE.md": "4a6c83617dc5308d77a1adb1c79f54ec085f280aa3acc33a6e5329a4aa80109c", + ".governance/error/GOV-TICKET-001.md": "61e110b93b6111fde243e538e4f34330df36ae29f5a65fb4f90b91d44c1b801e", + ".governance/error/GOV-TICKET-ACTIVITY.md": "f1bc9cab86c36028eed449f1c40a229131cb49141cbea35620580e2ef2d2b642", + ".governance/error/GOV-TICKET-ALLOCATION.md": "ef1b128ec726faa400470800bbd594b46150b9d9f2db15b3de0c4c841de5e1ea", + ".governance/error/GOV-VERSION.md": "b36de34adf8900f39e05ae2f92bfa892971143803fc263821728be587543ce0b", + ".governance/error/GOV-WORK-CONTINUITY.md": "de46a4adc51e8dd5880a83a6d9585ecdbbea4ae30a44ee6506c207ea2e8bc8eb", + ".governance/error/GOV-WORK-START.md": "e016dbaa6e1ec2b72292b9a34cda167e1a16e0be21969939743f5f62fbbe5f88", + ".governance/error/GOV-WORKSPACE-LIFECYCLE.md": "392b9f484ee26eff04a77d6c24d283413aa25d7e23e0d295ab3d1542f3e1ef3d", + ".governance/error/GOV-WORKTREE-OVERLAP.md": "65a2533f13e63d6ebeeb63c07adc0794ea9e04075a91e873eff2d79910239b0e", + ".governance/error/README.md": "e8486dd29f52ca3fee96ed6881a62c38141864cde5aa1adea2b16d22b2feefaa", + ".governance/generate_required_checks.py": "d43629ad727021d961b5132d109a9d4d81b5431bb3cf603b1c0afeece7ba196c", + ".governance/governance_check.py": "0b72d4b17d712715f3b8fe45c3b2a45eac22189f5e1cce1cb5fbedb7b903d0ec", + ".governance/idle_takeover.py": "a1c8ee2e9665d22028cfebe53ad12bc180ba1950ec5f32e3aa1efe74e926255a", + ".governance/intent.schema.json": "b0a75ee7b580425fd453a0bb93bfbd442930dadec625aef48bebae18e8e963a9", + ".governance/lock.schema.json": "ad80c98f800a4a3310870336dcdaf0aa689cc4988f71084d25d76bea2df1242f", + ".governance/managed-copies.json": "c94bb38d048d517d541ac8ad7fc47d3f531471d88c713634b1d5889fd8e871d7", + ".governance/manifest.base.json": "a0414b8b38c11b1686add999e843eb556e2640c47b5863ab3741e369e9659286", + ".governance/manifest.schema.json": "2194aa771caa7d84d2f4b5b8bdd5b7c06182ffcd0cb910a86a8228326967a9da", + ".governance/package-manifest.json": "7344b6768c4d06915c138fe3bd6ad69871532c8c5a90b0edcab9820ab18fa27e", + ".governance/physical-contracts.schema.json": "df69632961d6936d961d9cba21e82e96dac96a8b52d9b3e36ed7e13e5a01b403", + ".governance/policy-dsl.lock.json": "6c6534353a7b15f599d1e4ee46fa76096e14d98ad80005260a60ebaaaff33ba8", + ".governance/policy_dsl_check.py": "15439706ef54953ff90a20bbd2a89c9fef2e6ef9ec7a423025d9d597c78a848c", + ".governance/precommit_standard_update.py": "c91e2bf9ae9d6ccc77bce0e61450c818a5961edee5bfde3b60426da88e296b0f", + ".governance/prune_merged_worktrees.py": "32a88bf7df88195ed41659740daa114cfbbb8f0d3d8081aaab511d91b651b1fb", + ".governance/remediation-intent.schema.json": "844f834775174b4c0e10f4530f5ac66f918a6f315428d3d70c884b688832d29e", + ".governance/remediation-intent.template.dsl.json": "a3eb01c54fe678f3fcebb88103ac4eb02f5dd24016b2ba9552814b5e442dfb34", + ".governance/remediation_intent.py": "a816c2e66b806a4c8a6d471c5e90d40069de2918771a2ada7b013dc4b4e4f05d", + ".governance/repository_policy.py": "05583022d5486a2371dc039d3721ea73bedac3f9a5751ad2432b96f2e52a7042", + ".governance/required-checks.schema.json": "465f004d0e30f21e60e59c8b7860cc33db059e383e272e3caa267df7f24437f5", + ".governance/reusable-workflows.schema.json": "04c82369e8dff62ae580196ea64d2f8d28c10d4b097dc337a5bf59419a6fa4a1", + ".governance/seed_baseline.py": "072ba0ee47a656e61b746c8c09f768d6264b6d2889c6b46c8537a8c9d3eccab7", + ".governance/snapshot-migration.schema.json": "37e97ca683254a66d1a83576e3a4293e37663d38efb0cbc94e9deec1dd58b5ca", + ".governance/snapshot_allocation.py": "31c7fa7f94139ab221be61e6f2011a433fe09b5c1712aa5c00ef8cf9b2d7b878", + ".governance/snapshot_migration.py": "b030abe2d0bd8027f14bcf8182b55a403b0701aac8f24a5ce089c8adfc20cd44", + ".governance/stack-profiles.json": "47a3b899553968dfc5e0565c0de525f13aadde5dacae4556614572a731054f0e", + ".governance/standard-adoption-plan.schema.json": "4829ea370409f921eefe2624b40eb7157047e1ac27867a854c498cbf5de2b615", + ".governance/standard-adoption.schema.json": "d9c58e86d11ebd23174ed8a5c209c13ae96bd4cd34b7866ac8c61a66de19bc9b", + ".governance/standard-packs.json": "02419af2fa2bde66e5ccf93ed58061d56db7be12a91b868ea3e5a82c7f0e0910", + ".governance/standard-propagation-request.schema.json": "25b189eb11402137afc67d5a53c426a6b91e083dfa0ca03e720b0bbeebe33beb", + ".governance/standard_pack_check.py": "e5bee4c152ab295125d0ddd069b2a92a08ecee76e117834e3d47d5a1506ffd86", + ".governance/templates/conftest-worktree-bootstrap.py": "1901fb6924b844915479d5ef781d6bca16232f6f1d2b93dcf93bb2c70bd9663b", + ".governance/terminal-receipt-registry.schema.json": "799dac322e708421a1ff2dbb00408a47cd05f7021c87920d1d37a1e70fe671f9", + ".governance/terminal_lease_release.py": "5e8500ad5784d65083508f44b144f793288096f6911d2c84034bb56fd7060618", + ".governance/ticket-activity-override.schema.json": "070f22044fba29e1a09543d7236b3105aa9127b9c22229f3e3d9aa067c6c8d17", + ".governance/ticket-activity.json": "b73e881f64f940cd9e670a411713f18c6fe701f46278426bd44e173b8b418599", + ".governance/ticket-activity.schema.json": "f11f717702145982716ce16598e94c47e46fe6dfdf3377b7b0fe45efdf932953", + ".governance/ticket-allocation-receipt.schema.json": "c1cf2107a127aa1051e8ea73faa463247fa1e571cb2722d2dbc7c60f6369383c", + ".governance/ticket-allocation-request.schema.json": "4d38efa172b12411d88c824ba13210a19ecd75b84aa239961c520c3ce5aee2aa", + ".governance/ticket-allocation.schema.json": "a0e92bb340ea6f6ffc1a292af1c13ab18b734c726cad8a3a28b3bf201630c6ca", + ".governance/ticket_activity.py": "7b096f778833e2daa74823c6595dd22b99aceb68b6b097f35d1acf71e2ca963d", + ".governance/ticket_allocation.py": "06a9e1f2b986a2dddb1be44b769a8b1029f1d0c5f06fedc44a229646370ac0bf", + ".governance/ticket_index_merge_driver.py": "478a6aa42fa9ef8733756c7adbb2a569dd109c9b79a0c2aa033ba67b083beeac", + ".governance/ticket_input.py": "cb515f4af5585514c4ece6cf1de495eb74be832c3381914ea0f3faef8470d2c7", + ".governance/ticket_recovery.py": "c6442c6d16216e88550fa5bef223fd7274f064068a15aebaffb60c7f4932d276", + ".governance/ticket_restoration.py": "faa363a75ccd3a32908288d49df2661d9867a05d9458307405f0376e09b2b46d", + ".governance/ticket_storage.py": "957e6b8dd19c7c5b03915e762b940575f2f19b5a40b0f9ee71920dfeeaf66010", + ".governance/work-classification.dsl.json": "3a947c41938c0b8ef1717957f313ff9248764252735182de30b1f2d6878748b6", + ".governance/work-classification.schema.json": "f5c2b518238543589e4f8d3805cc6455e19d6919643644aaeae034abf472a467", + ".governance/work-continuity.schema.json": "5134e6884ddaa4fb3a0ffd200d281afb0f3b868b06f71d65e3da6b42f6fe0830", + ".governance/work-start-report.schema.json": "f6744ed44359a892f44cfdc8c825182a2ba72b855ec3aadce244327e7771ba51", + ".governance/work_continuity.py": "b1351150503159aadcd0f48fe568487b1863bb5dc350551237d7edd1c83a07f6", + ".governance/work_start_check.py": "fb0f07ce4e48d93ffb54c0b440a8f17a94b54dfed1b30812d3b6a77509daf5dd", + ".governance/workspace_lifecycle_check.py": "f264154a69c2c00a9218e6dbff87dcf2a3179768d94b4086374f001da6aa411f", + ".governance/worktree_guard.py": "16408421c577a8c2eedd24800b69c116b3af11a4046d9533a470d67e4e4d0f4a", + ".governance/worktree_overlap_check.py": "a98932f89e78fc146d58f95f234cedbb15b9cf062e8b8d7761e0f9b8cca19fd9", + ".governance/worktree_path_check.py": "106f91e6398564b96d5487dbd981ebb2078d7d366b327400b7d08467fa04c1fd", + ".governance/worktrees.lock.json": "da34dceef6573250b03ac471867f95d6cf4a96f2cb729df61efed51283c52f2c", + ".governance/worktrees.schema.json": "6b57184758bdceda5cd5ea98889427af1a762dd0afe801ac8e1279d26ccb2cb8", + ".subactor/.gitignore": "dd223aed5e053f94c6808ac434368c16eeca7e77218f8426e8cf5e46ae441d03", + ".subactor/manifest.json": "ab8b1cbe4052a6f0005a3c33a43fa2a70e8837cd32c18b4183d0d448c3a8cee2", + "AGENTS.md": "2273a46935c48f01ffc14be4ddb5d1de4a1de8118dec823b2fd769625870f1f0", + "CLAUDE.md": "628e743294cd4e23531eece8053595fec526080b20f2fac74248e43e5227442b", + "GEMINI.md": "f72a35f8a888b1727f4829fa33410e75a1e830a363cc7bf7c465e5c519535725", + "project/governance-check.bat": "5ece7d85b011a1421bea0b6a5359a9b95ab878c96254f8143979cbcd5adaf76a", + "project/governance-check.sh": "961deddc05c5cbecbaf72a76503ed6bba94ea2b9096ea83381fb7365932a9ca8", + "project/new-ticket.sh": "c4f3e65b1ae5d79fd0691af4ce3ab3ac752093af688f2b6d1d2cce2332806e1d", + "project/readme.sh": "21474eb3aafc34b36eed596e785c5e2bb4fefed0b13c2bd2e617f3269ec3fe9a", + "scripts/install-agent-hosts.sh": "0150b8e0273419d3ba388755e11ec318386d7337d130cbcff83a06f2b396c5e7", + "scripts/runtime.sh": "057d6b06de069f57abfdcad8e53c784c651bab7787c7a30053c8243aca8a5c34", + "wellmanifest_governance.py": "2c63a4af5b2b92c37df27a5d2d1c5644bfbb216133d858f85701555c64c6b8cf", + "worktree-guard.yaml": "8533a7c03a121a6df68b89cab212187313ffb0720aaf457941439148a25c3345" + }, + "schema": "new-project.lock/v1", + "standard": { + "id": "wellmanifest/new-project", + "publicationStatus": "published", + "sourceRepository": "wellmanifest/new-project", + "sourceRevision": "3926cecf965cd03a39cc992aa40bf82ae2e97a13", + "version": "0.20.131" + } +} diff --git a/.governance/manifest.schema.json b/.governance/manifest.schema.json new file mode 100644 index 0000000..c92cc55 --- /dev/null +++ b/.governance/manifest.schema.json @@ -0,0 +1,284 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/manifest.schema.json", + "title": "new-project governance manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema", "standard", "requiredFiles", "ticket", "docker", "governancePaths", "trustedApprovalSources", "coordination"], + "properties": { + "$schema": { "type": "string", "minLength": 1 }, + "schema": { "const": "new-project.governance/v2" }, + "standard": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version"], + "properties": { + "id": { "const": "wellmanifest/new-project" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } + } + }, + "repository": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "componentRoots"], + "properties": { + "mode": { "enum": ["standalone", "monorepo"] }, + "componentRoots": { + "type": "array", + "items": { "$ref": "#/$defs/glob" }, + "uniqueItems": true + } + }, + "allOf": [ + { + "if": { "properties": { "mode": { "const": "standalone" } }, "required": ["mode"] }, + "then": { "properties": { "componentRoots": { "maxItems": 0 } } } + }, + { + "if": { "properties": { "mode": { "const": "monorepo" } }, "required": ["mode"] }, + "then": { "properties": { "componentRoots": { "minItems": 1 } } } + } + ] + }, + "repositoryPolicy": { + "$ref": "#/$defs/repositoryPolicy" + }, + "domainContracts": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { "const": "none" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["mode", "commandsAndQueries", "events", "errors", "models"], + "properties": { + "mode": { "const": "cqrs" }, + "commandsAndQueries": { "const": "operations/index.json" }, + "events": { "const": "events/index.json" }, + "errors": { "const": "error/index.json" }, + "models": { "const": "operations/index.json#/models" } + } + } + ] + }, + "requiredFiles": { "type": "array", "items": { "$ref": "#/$defs/path" }, "uniqueItems": true }, + "governancePaths": { "type": "array", "items": { "$ref": "#/$defs/glob" }, "uniqueItems": true }, + "trustedApprovalSources": { + "type": "array", + "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.com/attestations/validator/v1" + } + } + }, + "ticket": { + "type": "object", + "additionalProperties": false, + "required": ["root", "directoryPattern", "requiredFiles", "requiredAgentFiles", "activeStatuses", "nonActiveStatuses", "closedStatuses", "implementationStates", "intentFile"], + "properties": { + "root": { "$ref": "#/$defs/path" }, + "directoryPattern": { "type": "string", "minLength": 1 }, + "requiredFiles": { "const": ["README.md", "intent.json"] }, + "requiredAgentFiles": { "const": [] }, + "activeStatuses": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true }, + "nonActiveStatuses": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true }, + "closedStatuses": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true }, + "implementationStates": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true }, + "intentFile": { "$ref": "#/$defs/path" } + } + }, + "docker": { + "type": "object", + "additionalProperties": false, + "required": ["required", "dockerfiles", "composeFiles"], + "properties": { + "required": { "type": "boolean" }, + "dockerfiles": { "type": "array", "items": { "$ref": "#/$defs/path" }, "minItems": 1 }, + "composeFiles": { "type": "array", "items": { "$ref": "#/$defs/path" }, "minItems": 1 } + } + }, + "coordination": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "maxActiveTicketsPerWorkstream", "rejectActiveScopeOverlap", "workstreams", "integration"], + "properties": { + "mode": { "const": "workstreams" }, + "maxActiveTicketsPerWorkstream": { "type": "integer", "minimum": 1 }, + "rejectActiveScopeOverlap": { "type": "boolean" }, + "idleTakeover": { + "description": "Idle ticket takeover (new-project.idle-takeover/v1); see error/GOV-IDLE-TAKEOVER.md.", + "type": "object", + "additionalProperties": false, + "required": ["enabled", "idleMinutes", "leaseMinutes"], + "properties": { + "enabled": { "type": "boolean" }, + "idleMinutes": { "type": "integer", "minimum": 5, "maximum": 240 }, + "leaseMinutes": { "type": "integer", "minimum": 15, "maximum": 480 } + } + }, + "workstreams": { + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["ownedPaths"], + "properties": { + "ownedPaths": { "type": "array", "items": { "$ref": "#/$defs/glob" }, "minItems": 1, "uniqueItems": true } + } + } + }, + "integration": { + "type": "object", + "additionalProperties": false, + "required": ["workstream", "requiredForPaths"], + "properties": { + "workstream": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "requiredForPaths": { "type": "array", "items": { "$ref": "#/$defs/glob" }, "uniqueItems": true } + } + } + } + }, + "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": 240 }, + "checkpointMinutes": { "type": "integer", "minimum": 1, "maximum": 239 }, + "allowedComplexityClasses": { + "type": "array", + "items": { "enum": ["XS", "S", "M", "L"] }, + "minItems": 1, + "uniqueItems": true + }, + "maxImplementationFiles": { "type": "integer", "minimum": 1 }, + "maxAffectedComponents": { "type": "integer", "minimum": 1 }, + "maxPublicInterfaceChanges": { "type": "integer", "minimum": 0 }, + "maxRuntimeDependencies": { "type": "integer", "minimum": 0 }, + "profiles": { + "type": "object", + "additionalProperties": false, + "properties": { + "XS": { "$ref": "#/$defs/deliveryProfile" }, + "S": { "$ref": "#/$defs/deliveryProfile" }, + "M": { "$ref": "#/$defs/deliveryProfile" }, + "L": { "$ref": "#/$defs/deliveryProfile" } + } + }, + "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"] }, + "uniqueItems": true, + "default": [] + } + }, + "$defs": { + "deliveryProfile": { + "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 } + } + }, + "repositoryPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["profile", "enforcement", "delivery", "tickets"], + "properties": { + "profile": { "enum": ["ticket-worktree", "main-only-planfile", "main-only-files", "protected-pr", "local-audit"] }, + "enforcement": { "enum": ["audit", "enforce"] }, + "delivery": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "checkout", "targetBranches"], + "properties": { + "mode": { "enum": ["ticket-branch", "main-only"] }, + "checkout": { "enum": ["linked-worktree", "primary"] }, + "targetBranches": { "type": "array", "items": { "$ref": "#/$defs/branch" }, "minItems": 1, "uniqueItems": true } + } + }, + "tickets": { + "type": "object", + "additionalProperties": false, + "required": ["backend", "idPattern", "referenceRequired"], + "properties": { + "backend": { "enum": ["files", "sqlite", "planfile"] }, + "idPattern": { "type": "string", "minLength": 1 }, + "referenceRequired": { "type": "boolean" }, + "adapterPath": { "$ref": "#/$defs/path" }, + "sourcePaths": { "type": "array", "items": { "$ref": "#/$defs/path" }, "minItems": 1, "uniqueItems": true } + } + } + } + }, + "path": { "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..417fcab --- /dev/null +++ b/.governance/package-manifest.json @@ -0,0 +1,779 @@ +{ + "schema": "new-project.package-manifest/v1", + "files": [ + { + "source": "scripts/terminal_lease_release.py", + "target": ".governance/terminal_lease_release.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/snapshot_allocation.py", + "target": ".governance/snapshot_allocation.py", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/ticket_recovery.py", + "target": ".governance/ticket_recovery.py", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/ticket_restoration.py", + "target": ".governance/ticket_restoration.py", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/work_start_check.py", + "target": ".governance/work_start_check.py", + "strategy": "managed", + "executable": true + }, + { + "source": "governance/work-start-report.schema.json", + "target": ".governance/work-start-report.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-WORK-START.md", + "target": ".governance/error/GOV-WORK-START.md", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/AGENTS.template.md", + "target": "AGENTS.md", + "strategy": "managed", + "executable": false + }, + { + "source": "docs/AGENT_DECISIONS.md", + "target": ".governance/AGENT_DECISIONS.md", + "strategy": "managed", + "executable": false + }, + { + "source": ".subactor/.gitignore", + "target": ".subactor/.gitignore", + "strategy": "managed", + "executable": false + }, + { + "source": ".subactor/manifest.json", + "target": ".subactor/manifest.json", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/tests/conftest-worktree-bootstrap.template.py", + "target": ".governance/templates/conftest-worktree-bootstrap.py", + "strategy": "managed", + "executable": false + }, + { + "source": "project.sh", + "target": "project.sh", + "strategy": "seed", + "executable": true + }, + { + "source": "project.bat", + "target": "project.bat", + "strategy": "seed", + "executable": false + }, + { + "source": "governance/approval-evidence.schema.json", + "target": ".governance/approval-evidence.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/adoption-bindings.json", + "target": ".governance/adoption-bindings.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/adoption-bindings.schema.json", + "target": ".governance/adoption-bindings.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/change-evaluation.schema.json", + "target": ".governance/change-evaluation.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/diagnostics.json", + "target": ".governance/diagnostics.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/diagnostics.schema.json", + "target": ".governance/diagnostics.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/ticket-allocation.json", + "target": ".governance/ticket-allocation.json", + "strategy": "extendable", + "executable": false + }, + { + "source": "governance/ticket-allocation.schema.json", + "target": ".governance/ticket-allocation.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/ticket-allocation-request.schema.json", + "target": ".governance/ticket-allocation-request.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/ticket-allocation-receipt.schema.json", + "target": ".governance/ticket-allocation-receipt.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/ticket-activity.json", + "target": ".governance/ticket-activity.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/ticket-activity.schema.json", + "target": ".governance/ticket-activity.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/ticket-activity-override.schema.json", + "target": ".governance/ticket-activity-override.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/terminal-receipt-registry.schema.json", + "target": ".governance/terminal-receipt-registry.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/work-continuity.schema.json", + "target": ".governance/work-continuity.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/remediation-intent.schema.json", + "target": ".governance/remediation-intent.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/remediation-intent.template.dsl.json", + "target": ".governance/remediation-intent.template.dsl.json", + "strategy": "managed", + "executable": false + }, + { + "source": "error/README.md", + "target": ".governance/error/README.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-HOST-MERGE.md", + "target": ".governance/error/GOV-HOST-MERGE.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-PUSH.md", + "target": ".governance/error/GOV-PUSH.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-APPROVAL.md", + "target": ".governance/error/GOV-APPROVAL.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-REMEDIATION-INTENT.md", + "target": ".governance/error/GOV-REMEDIATION-INTENT.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-TICKET-001.md", + "target": ".governance/error/GOV-TICKET-001.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-TICKET-ALLOCATION.md", + "target": ".governance/error/GOV-TICKET-ALLOCATION.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-TICKET-ACTIVITY.md", + "target": ".governance/error/GOV-TICKET-ACTIVITY.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-WORK-CONTINUITY.md", + "target": ".governance/error/GOV-WORK-CONTINUITY.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-WORKSPACE-LIFECYCLE.md", + "target": ".governance/error/GOV-WORKSPACE-LIFECYCLE.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-WORKTREE-OVERLAP.md", + "target": ".governance/error/GOV-WORKTREE-OVERLAP.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-CHANGE-LEASE.md", + "target": ".governance/error/GOV-CHANGE-LEASE.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-PHYS.md", + "target": ".governance/error/GOV-PHYS.md", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/physical-contracts.schema.json", + "target": ".governance/physical-contracts.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "subprojects/change-lease/change-lease.schema.json", + "target": ".governance/change-lease.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "subprojects/worktrees/worktrees.schema.json", + "target": ".governance/worktrees.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "subprojects/worktrees/conformance.py", + "target": ".governance/worktree_path_check.py", + "strategy": "managed", + "executable": true + }, + { + "source": "governance/worktrees.lock.json", + "target": ".governance/worktrees.lock.json", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/change_lease_check.py", + "target": ".governance/change_lease_check.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/idle_takeover.py", + "target": ".governance/idle_takeover.py", + "strategy": "managed", + "executable": true + }, + { + "source": "error/GOV-IDLE-TAKEOVER.md", + "target": ".governance/error/GOV-IDLE-TAKEOVER.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-INTENT.md", + "target": ".governance/error/GOV-INTENT.md", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/worktree_overlap_check.py", + "target": ".governance/worktree_overlap_check.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/ticket_activity.py", + "target": ".governance/ticket_activity.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/work_continuity.py", + "target": ".governance/work_continuity.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/ticket_input.py", + "target": ".governance/ticket_input.py", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/ticket_storage.py", + "target": ".governance/ticket_storage.py", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/ticket_allocation.py", + "target": ".governance/ticket_allocation.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/worktree_guard.py", + "target": ".governance/worktree_guard.py", + "strategy": "managed", + "executable": true + }, + { + "source": "template/files/worktree-guard.template.yaml", + "target": "worktree-guard.yaml", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/required-checks.template.json", + "target": ".governance/required-checks.json", + "strategy": "extendable", + "executable": false + }, + { + "source": "governance/required-checks.schema.json", + "target": ".governance/required-checks.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/decision-record.schema.json", + "target": ".governance/decision-record.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/check_required_checks.py", + "target": ".governance/check_required_checks.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/decision_record.py", + "target": ".governance/decision_record.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/remediation_intent.py", + "target": ".governance/remediation_intent.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/branch_lifecycle_check.py", + "target": ".governance/branch_lifecycle_check.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/workspace_lifecycle_check.py", + "target": ".governance/workspace_lifecycle_check.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/ticket_index_merge_driver.py", + "target": ".governance/ticket_index_merge_driver.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/prune_merged_worktrees.py", + "target": ".governance/prune_merged_worktrees.py", + "strategy": "managed", + "executable": true + }, + { + "source": "template/files/.gitattributes", + "target": ".gitattributes", + "strategy": "seed", + "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": true + }, + { + "source": "scripts/policy_dsl_check.py", + "target": ".governance/policy_dsl_check.py", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/policy-dsl.lock.json", + "target": ".governance/policy-dsl.lock.json", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/repository_policy.py", + "target": ".governance/repository_policy.py", + "strategy": "managed", + "executable": true + }, + { + "source": "template/files/new-project-governance.workflow.yml", + "target": ".github/workflows/new-project-governance.yml", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/new-project-branch-hygiene.workflow.yml", + "target": ".github/workflows/new-project-branch-hygiene.yml", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/runtime.sh", + "target": "scripts/runtime.sh", + "strategy": "managed", + "executable": true + }, + { + "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.base.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/manifest.default.json", + "target": ".governance/manifest.json", + "strategy": "extendable", + "executable": false + }, + { + "source": "error/GOV-AGENT-HOST.md", + "target": ".governance/error/GOV-AGENT-HOST.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-STANDARD-UPDATE.md", + "target": ".governance/error/GOV-STANDARD-UPDATE.md", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-PACKAGING.md", + "target": ".governance/error/GOV-PACKAGING.md", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/CLAUDE.template.md", + "target": "CLAUDE.md", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/GEMINI.template.md", + "target": "GEMINI.md", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/cursor-rule.template.mdc", + "target": ".cursor/rules/new-project-standard.mdc", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/pre-commit.template.sh", + "target": ".githooks/pre-commit", + "strategy": "managed", + "executable": true + }, + { + "source": "template/files/commit-msg.template.sh", + "target": ".githooks/commit-msg", + "strategy": "managed", + "executable": true + }, + { + "source": "template/files/pre-push.template.sh", + "target": ".githooks/pre-push", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/precommit_standard_update.py", + "target": ".governance/precommit_standard_update.py", + "strategy": "managed", + "executable": true + }, + { + "source": "governance/agent-hosts.json", + "target": ".governance/agent-hosts.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/agent-hosts.schema.json", + "target": ".governance/agent-hosts.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/agent_host_check.py", + "target": ".governance/agent_host_check.py", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/install-agent-hosts.sh", + "target": "scripts/install-agent-hosts.sh", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/generate_required_checks.py", + "target": ".governance/generate_required_checks.py", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/aider.template.yml", + "target": ".aider.conf.yml", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/copilot-instructions.template.md", + "target": ".github/copilot-instructions.md", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/wellmanifest_governance.py", + "target": "wellmanifest_governance.py", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/standard-packs.json", + "target": ".governance/standard-packs.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/standard-adoption.schema.json", + "target": ".governance/standard-adoption.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/standard-adoption.default.json", + "target": ".governance/standard-adoption.json", + "strategy": "seed", + "executable": false + }, + { + "source": "scripts/standard_pack_check.py", + "target": ".governance/standard_pack_check.py", + "strategy": "managed", + "executable": true + }, + { + "source": "scripts/branch_intent_reconciliation.py", + "target": ".governance/branch_intent_reconciliation.py", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/branch-intent-reconciliation.schema.json", + "target": ".governance/branch-intent-reconciliation.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "docs/BRANCH_INTENT_RECONCILIATION.md", + "target": ".governance/docs/BRANCH_INTENT_RECONCILIATION.md", + "strategy": "managed", + "executable": false + }, + { + "source": "template/files/LOCAL_CI_PUBLICATION.template.md", + "target": ".governance/docs/LOCAL_CI_PUBLICATION.md", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/snapshot_migration.py", + "target": ".governance/snapshot_migration.py", + "strategy": "managed", + "executable": true + }, + { + "source": "governance/snapshot-migration.schema.json", + "target": ".governance/snapshot-migration.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-SNAPSHOT-MIGRATION.md", + "target": ".governance/error/GOV-SNAPSHOT-MIGRATION.md", + "strategy": "managed", + "executable": false + }, + { + "source": "docs/information/snapshot-migration.md", + "target": ".governance/docs/SNAPSHOT_MIGRATION.md", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/managed-copies.json", + "target": ".governance/managed-copies.json", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-ARCHITECTURE-001.md", + "target": ".governance/error/GOV-ARCHITECTURE-001.md", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/reusable-workflows.schema.json", + "target": ".governance/reusable-workflows.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "error/GOV-VERSION.md", + "target": ".governance/error/GOV-VERSION.md", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/standard-adoption-plan.schema.json", + "target": ".governance/standard-adoption-plan.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "governance/standard-propagation-request.schema.json", + "target": ".governance/standard-propagation-request.schema.json", + "strategy": "managed", + "executable": false + }, + { + "source": "scripts/seed_baseline.py", + "target": ".governance/seed_baseline.py", + "strategy": "managed", + "executable": true + } + ] +} diff --git a/.governance/physical-contracts.schema.json b/.governance/physical-contracts.schema.json new file mode 100644 index 0000000..210a251 --- /dev/null +++ b/.governance/physical-contracts.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/physical-contracts.schema.json", + "title": "Adopter physical interface contracts", + "description": "Files whose values carry physical meaning. A change to a matching path requires intent physicalChanges (P-PHYS-001).", + "type": "object", + "additionalProperties": false, + "required": ["schema", "contracts"], + "properties": { + "schema": { "const": "new-project.physical-contracts/v1" }, + "contracts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "paths", "properties", "hazard", "acceptance"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9.-]*$" }, + "paths": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "properties": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["pin-assignment", "active-level", "pull", "capability-set", "unit-scale", "limit-range", "timing"] } + }, + "hazard": { "enum": ["motion", "pressure", "electrical", "thermal", "none"] }, + "acceptance": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/.governance/policy-dsl.lock.json b/.governance/policy-dsl.lock.json new file mode 100644 index 0000000..0f47bd6 --- /dev/null +++ b/.governance/policy-dsl.lock.json @@ -0,0 +1,17 @@ +{ + "schema": "new-project.policy-dsl-lock/v1", + "dependency": { + "id": "wellmanifest/policy-dsl", + "version": "0.2.0-dev", + "sourceRepository": "wellmanifest/policy-dsl", + "sourceRevision": "0ffa41efd903d3e0fc3dbf4431f2771b34c1f70e", + "sourcePath": "tests/policy_dsl_check.py", + "sourceSha256": "15439706ef54953ff90a20bbd2a89c9fef2e6ef9ec7a423025d9d597c78a848c" + }, + "installation": { + "packageSourcePath": "scripts/policy_dsl_check.py", + "managedTargetPath": ".governance/policy_dsl_check.py", + "lockTargetPath": ".governance/policy-dsl.lock.json", + "networkRequired": false + } +} diff --git a/.governance/policy_dsl_check.py b/.governance/policy_dsl_check.py new file mode 100644 index 0000000..04dd513 --- /dev/null +++ b/.governance/policy_dsl_check.py @@ -0,0 +1,1176 @@ +#!/usr/bin/env python3 +"""Dependency-free Policy DSL v1 parser and conformance checker. + +This module parses inert data and emits closed Policy IR. It intentionally has +no action executor and no shell integration. +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + + +ROOT = Path(__file__).resolve().parents[1] +SYMBOL = r"[A-Za-z_][A-Za-z0-9_.:/-]*" +STABLE_ID = r"[A-Z][A-Z0-9_-]*" +SCALAR_TYPES = {"STRING", "INTEGER", "NUMBER", "BOOLEAN", "URL", "PATH"} +SAFE_CANDIDATE_OPCODES = {"REQUIRE", "ALLOW", "REPORT", "VALIDATE", "RECORD"} + + +class PolicyError(Exception): + def __init__(self, code: str, message: str, line: int | None = None): + self.code = code + self.line = line + where = f"line {line}: " if line is not None else "" + super().__init__(f"{code}: {where}{message}") + + +@dataclass(frozen=True) +class Statement: + text: str + line: int + + +@dataclass(frozen=True) +class Token: + kind: str + value: str + line: int + + +TOKEN_RE = re.compile( + r"\s*(?:" + r"(?P\"(?:\\[\"\\/bfnrt]|\\u[0-9a-fA-F]{4}|[^\"\\\x00-\x1f])*\")|" + r"(?P[0-9]+(?:\.[0-9]+)?)|" + r"(?P\{" + SYMBOL + r"\})|" + r"(?P->)|" + r"(?P!=|<=|>=|=|<|>|\+|-|\*|/|%)|" + r"(?P\[)|(?P\])|" + r"(?P\()|(?P\))|(?P,)|" + r"(?P" + SYMBOL + r")" + r")" +) + + +def _strip_comment(line: str) -> str: + quoted = False + escaped = False + for index, char in enumerate(line): + if escaped: + escaped = False + elif char == "\\" and quoted: + escaped = True + elif char == '"': + quoted = not quoted + elif char == "#" and not quoted: + return line[:index] + return line + + +def statements(source: str) -> list[Statement]: + if source.startswith("\ufeff"): + raise PolicyError("POLICY-SYNTAX-001", "UTF-8 BOM is forbidden", 1) + if "\r" in source or "\t" in source: + raise PolicyError("POLICY-SYNTAX-001", "CR and tab characters are forbidden") + for char in source: + if char != "\n" and (ord(char) < 0x20 or ord(char) == 0x7F): + raise PolicyError("POLICY-SYNTAX-001", "control character is forbidden") + + result: list[Statement] = [] + buffer: list[str] = [] + start = 1 + square = round_ = 0 + continuation = False + + def emit() -> None: + nonlocal buffer, continuation + result.append(Statement(" ".join(buffer), start)) + buffer = [] + continuation = False + + for number, raw in enumerate(source.splitlines(), 1): + line = _strip_comment(raw).strip() + if not line: + if buffer and square == 0 and round_ == 0 and continuation: + emit() + continue + indented = raw[:1].isspace() + block_keyword = line == "END" or line.startswith( + ("RULE ", "DO ", "FORBID ", "ASSERT ", "NEXT ", "STATE ", "STATES ", "TRANSITION ", "TEMPLATE ", "SCOPE ") + ) + if buffer and square == 0 and round_ == 0 and continuation and (not indented or block_keyword): + emit() + # Shell syntax outside a quoted literal violates the inert boundary. + unquoted = re.sub(r'"(?:\\.|[^"\\])*"', '""', line) + if "$(" in unquoted or "`" in unquoted or re.search(r"(^|\s)(?:;|&&|\|\|)(\s|$)", unquoted): + raise PolicyError("POLICY-SECURITY-001", "shell syntax is not Policy DSL", number) + if not buffer: + start = number + buffer.append(line) + quoted = escaped = False + for char in line: + if escaped: + escaped = False + elif char == "\\" and quoted: + escaped = True + elif char == '"': + quoted = not quoted + elif not quoted: + square += (char == "[") - (char == "]") + round_ += (char == "(") - (char == ")") + if square < 0 or round_ < 0: + raise PolicyError("POLICY-SYNTAX-001", "unbalanced delimiter", number) + if square == 0 and round_ == 0: + if line.endswith(" WHEN") or (continuation and indented): + continuation = True + else: + emit() + if buffer and square == 0 and round_ == 0: + emit() + if buffer or square or round_: + raise PolicyError("POLICY-SYNTAX-001", "unterminated multiline statement", start) + return result + + +POLICY_SCHEMA_IDS = {"wellmanifest.policy/v1", "policy-sh@1"} +# Closed registry of independent record kinds that Markdown carriers may embed +# in `dsl` fences next to a Policy DSL document. Extending it is a spec change. +EMBEDDED_RECORD_STARTS = ("DECISION ",) + + +def extract_markdown(source: str) -> str: + """Extract the canonical distributed Policy DSL document from Markdown. + + Every `dsl` fence is classified. Unclassifiable fences fail closed so that + normative-looking text is never dropped silently. + """ + blocks: list[tuple[str, int]] = [] + current: list[str] | None = None + opened = 0 + for number, raw in enumerate(source.splitlines(), 1): + if current is None: + if raw.strip() == "```dsl": + current = [] + opened = number + elif raw.strip() == "```": + blocks.append(("\n".join(current) + "\n", opened)) + current = None + else: + current.append(raw) + if current is not None: + raise PolicyError("POLICY-SYNTAX-001", "unterminated dsl fence", opened) + + header: str | None = None + selected: list[str] = [] + binding_start = re.compile(r"^" + SYMBOL + r"\s+(?:=|IN)\s+") + schema_line = re.compile(r'^SCHEMA\s+"([^"]*)"$') + policy_starts = ( + "RULE ", + "STATE ", + "STATES ", + "TRANSITION ", + "TEMPLATE ", + "SCOPE ", + "ENV_FILE ", + "VARIABLE ", + "SECRET ", + "ASSERT ", + ) + for block, opened in blocks: + significant = [_strip_comment(line).strip() for line in block.splitlines()] + significant = [line for line in significant if line] + if not significant: + continue + first = significant[0] + if re.fullmatch(r"DOCUMENT <[^>]+>", first): + continue # illustrative placeholder metadata + if re.fullmatch(r"DOCUMENT " + SYMBOL, first): + schemas = {match.group(1) for line in significant if (match := schema_line.match(line))} + if schemas - POLICY_SCHEMA_IDS or header is not None: + continue # independent embedded document + header = block + continue + if first.startswith(EMBEDDED_RECORD_STARTS): + continue # registered independent record kind + if first.startswith(policy_starts) or binding_start.match(first): + if header is None: + raise PolicyError("POLICY-SYNTAX-001", "Policy DSL fence precedes the DOCUMENT header", opened) + selected.append(block) + continue + raise PolicyError( + "POLICY-SYNTAX-001", + "dsl fence is neither Policy DSL, placeholder metadata, an independent DOCUMENT nor a registered record; " + "express normative text as Policy DSL or use a non-dsl fence", + opened, + ) + if header is None: + raise PolicyError("POLICY-SYNTAX-001", "Markdown has no concrete Policy DSL DOCUMENT fence") + return header + "".join(selected) + + +def tokenize(text: str, line: int) -> list[Token]: + result: list[Token] = [] + position = 0 + while position < len(text): + match = TOKEN_RE.match(text, position) + if not match: + raise PolicyError("POLICY-SYNTAX-001", f"unexpected token near {text[position:position + 16]!r}", line) + kind = match.lastgroup or "" + value = match.group(kind) + if kind == "SYMBOL" and value in {"AND", "OR", "NOT", "IN"}: + kind = "OP" + result.append(Token(kind, value, line)) + position = match.end() + result.append(Token("EOF", "", line)) + return result + + +class ExpressionParser: + PRECEDENCE = { + "OR": 10, + "AND": 20, + "IN": 30, + "=": 40, + "!=": 40, + "<": 50, + "<=": 50, + ">": 50, + ">=": 50, + "+": 60, + "-": 60, + "*": 70, + "/": 70, + "%": 70, + } + + def __init__(self, tokens: list[Token]): + self.tokens = tokens + self.index = 0 + + @property + def current(self) -> Token: + return self.tokens[self.index] + + def take(self, kind: str | None = None, value: str | None = None) -> Token: + current = self.current + if kind is not None and current.kind != kind: + raise PolicyError("POLICY-SYNTAX-001", f"expected {kind}, found {current.value!r}", current.line) + if value is not None and current.value != value: + raise PolicyError("POLICY-SYNTAX-001", f"expected {value!r}, found {current.value!r}", current.line) + self.index += 1 + return current + + def expression(self, minimum: int = 0) -> dict[str, Any]: + current = self.current + if current.kind == "OP" and current.value in {"NOT", "-"}: + self.take() + left = {"node": "unary", "operator": current.value, "operand": self.expression(80)} + else: + left = self.primary() + while self.current.kind == "OP" and self.current.value in self.PRECEDENCE: + operator = self.current.value + precedence = self.PRECEDENCE[operator] + if precedence < minimum: + break + self.take() + right = self.expression(precedence + 1) + left = {"node": "binary", "operator": operator, "left": left, "right": right} + return left + + def primary(self) -> dict[str, Any]: + current = self.current + if current.kind == "STRING": + self.take() + return {"node": "literal", "value": json.loads(current.value)} + if current.kind == "NUMBER": + self.take() + value: int | float = float(current.value) if "." in current.value else int(current.value) + return {"node": "literal", "value": value} + if current.kind == "SYMBOL": + self.take() + if current.value in {"TRUE", "FALSE", "true", "false"}: + return {"node": "literal", "value": current.value in {"TRUE", "true"}} + return {"node": "symbol", "name": current.value} + if current.kind == "PLACEHOLDER": + self.take() + return {"node": "symbol", "name": current.value} + if current.kind == "LBRACK": + return self.collection("RBRACK", "list") + if current.kind == "LPAREN": + return self.collection("RPAREN", "group") + raise PolicyError("POLICY-SYNTAX-001", f"expected expression, found {current.value!r}", current.line) + + def collection(self, closing: str, kind: str) -> dict[str, Any]: + self.take() + items: list[dict[str, Any]] = [] + while self.current.kind != closing: + if self.current.kind == "EOF": + raise PolicyError("POLICY-SYNTAX-001", "unterminated collection", self.current.line) + items.append(self.expression()) + if self.current.kind == "COMMA": + self.take() + elif self.current.kind != closing: + # A parenthesized action payload may be a typed sequence. + if kind != "group": + raise PolicyError("POLICY-SYNTAX-001", "list items require commas", self.current.line) + self.take(closing) + if kind == "group" and len(items) == 1: + return items[0] + node = "list" if kind == "list" else "sequence" + if node == "sequence" and len(items) < 2: + raise PolicyError("POLICY-SYNTAX-001", "empty grouping is forbidden", self.current.line) + return {"node": node, "items": items} + + def complete(self) -> dict[str, Any]: + value = self.expression() + self.take("EOF") + return value + + def sequence(self) -> dict[str, Any] | None: + items: list[dict[str, Any]] = [] + while self.current.kind != "EOF": + if self.current.kind == "COMMA": + self.take() + continue + if self.current.kind == "OP" and self.current.value not in {"NOT", "-"}: + # Domain actions use words such as IN/AND as typed prepositions + # when no left operand exists; they remain symbols, not text. + items.append({"node": "symbol", "name": self.take().value}) + continue + items.append(self.expression()) + if not items: + return None + return items[0] if len(items) == 1 else {"node": "sequence", "items": items} + + +def parse_expression(text: str, line: int) -> dict[str, Any]: + return ExpressionParser(tokenize(text, line)).complete() + + +def parse_condition(text: str, line: int) -> dict[str, Any]: + value = ExpressionParser(tokenize(text, line)).sequence() + if value is None: + raise PolicyError("POLICY-SYNTAX-001", "condition must not be empty", line) + return value + + +def parse_action(text: str, line: int) -> dict[str, Any]: + tokens = tokenize(text, line) + if tokens[0].kind != "SYMBOL" or not re.fullmatch(r"[A-Z][A-Z0-9_]*", tokens[0].value): + raise PolicyError("POLICY-SYNTAX-001", "action requires an uppercase opcode", line) + opcode = tokens[0].value + body = tokens[1:-1] + depth = 0 + guard_at: int | None = None + for index, token in enumerate(body): + if token.kind in {"LPAREN", "LBRACK"}: + depth += 1 + elif token.kind in {"RPAREN", "RBRACK"}: + depth -= 1 + elif depth == 0 and token.kind == "SYMBOL" and token.value == "WHEN": + guard_at = index + break + payload_tokens = body if guard_at is None else body[:guard_at] + guard_tokens = [] if guard_at is None else body[guard_at + 1:] + payload = ExpressionParser(payload_tokens + [Token("EOF", "", line)]).sequence() + guard = None + if guard_at is not None: + if not guard_tokens: + raise PolicyError("POLICY-SYNTAX-001", "action WHEN requires a condition", line) + guard = ExpressionParser(guard_tokens + [Token("EOF", "", line)]).sequence() + return {"kind": "action", "opcode": opcode, "payload": payload, "guard": guard} + + +def parse_next(text: str, line: int) -> list[dict[str, Any]]: + first = re.fullmatch(r"(" + SYMBOL + r")(.*)", text) + if not first: + raise PolicyError("POLICY-SYNTAX-001", "NEXT requires a target", line) + target, tail = first.group(1), first.group(2).strip() + if not tail: + return [{"target": target, "condition": None}] + if tail.startswith("OR "): + targets = [target] + tail[3:].split(" OR ") + if any(not re.fullmatch(SYMBOL, value) for value in targets): + raise PolicyError("POLICY-SYNTAX-001", "invalid NEXT targets", line) + return [{"target": value, "condition": None} for value in targets] + if not tail.startswith("WHEN "): + raise PolicyError("POLICY-SYNTAX-001", "NEXT accepts OR or WHEN after its target", line) + condition_text = tail[5:] + fallback: str | None = None + fallback_match = re.fullmatch(r"(.+) OR (" + SYMBOL + r")", condition_text) + if fallback_match: + condition_text, fallback = fallback_match.group(1), fallback_match.group(2) + result = [{"target": target, "condition": parse_condition(condition_text, line)}] + if fallback is not None: + result.append({"target": fallback, "condition": None}) + return result + + +def _quoted(value: str, line: int) -> str: + try: + parsed = json.loads(value) + except json.JSONDecodeError as error: + raise PolicyError("POLICY-SYNTAX-001", "invalid JSON string", line) from error + if not isinstance(parsed, str): + raise PolicyError("POLICY-SYNTAX-001", "expected string", line) + return parsed + + +def _parse_symbol_list(text: str, line: int) -> list[str]: + expr = parse_expression(text, line) + if expr.get("node") != "list" or not expr.get("items"): + raise PolicyError("POLICY-SYNTAX-001", "expected non-empty state symbol list", line) + names: list[str] = [] + for item in expr["items"]: + if item.get("node") != "symbol" or not re.fullmatch(SYMBOL, item.get("name", "")): + raise PolicyError("POLICY-SYNTAX-001", "state list items must be valid symbols", line) + names.append(item["name"]) + return names + + +class PolicyParser: + def __init__(self, source: str): + self.items = statements(source) + self.index = 0 + self.templates: dict[str, dict[str, Any]] = {} + self.uncompacted_rules: list[dict[str, Any]] = [] + self.uncompacted_transitions: list[dict[str, Any]] = [] + self.scoped_rule_count = 0 + self.state_set_transition_count = 0 + + @property + def current(self) -> Statement | None: + return self.items[self.index] if self.index < len(self.items) else None + + def take(self) -> Statement: + item = self.current + if item is None: + raise PolicyError("POLICY-SYNTAX-001", "unexpected end of document") + self.index += 1 + return item + + def required_match(self, pattern: str, label: str) -> re.Match[str]: + item = self.take() + match = re.fullmatch(pattern, item.text) + if not match: + raise PolicyError("POLICY-SYNTAX-001", f"expected {label}", item.line) + return match + + def parse(self) -> dict[str, Any]: + name = self.required_match(r"DOCUMENT (" + SYMBOL + r")", "DOCUMENT").group(1) + version = int(self.required_match(r"VERSION ([1-9][0-9]*)", "VERSION").group(1)) + language: str | None = None + if self.current and self.current.text.startswith("LANGUAGE "): + language = self.required_match(r"LANGUAGE (" + SYMBOL + r")", "LANGUAGE").group(1) + mode = self.required_match(r"MODE (STRICT|PROCEDURAL)", "MODE").group(1) + purpose = policy = None + if self.current and self.current.text.startswith("PURPOSE "): + item = self.take() + match = re.fullmatch(r"PURPOSE (\".*\")", item.text) + if not match: + raise PolicyError("POLICY-SYNTAX-001", "invalid PURPOSE", item.line) + purpose = _quoted(match.group(1), item.line) + if self.current and self.current.text.startswith("POLICY "): + item = self.take() + match = re.fullmatch(r"POLICY (\".*\")", item.text) + if not match: + raise PolicyError("POLICY-SYNTAX-001", "invalid POLICY", item.line) + policy = _quoted(match.group(1), item.line) + + result: dict[str, Any] = { + "schema": "wellmanifest.policy/ir/v1", + "dialect": "wellmanifest.policy/v1", + "language_version": "1", + "document": {"name": name, "version": version, "language": language, "mode": mode, "purpose": purpose, "policy": policy}, + "environment": [], + "bindings": [], + "rules": [], + "assertions": [], + "states": [], + "transitions": [], + } + while self.current is not None: + text = self.current.text + if text.startswith("ENV_FILE ") or text.startswith("VARIABLE ") or text.startswith("SECRET "): + result["environment"].append(self.parse_environment()) + elif text.startswith("RULE "): + result["rules"].append(self.parse_rule()) + elif text.startswith("STATE "): + item = self.take() + match = re.fullmatch(r"STATE (" + SYMBOL + r")", item.text) + if not match: + raise PolicyError("POLICY-SYNTAX-001", "invalid STATE", item.line) + result["states"].append(match.group(1)) + elif text.startswith("STATES "): + result["states"].extend(self.parse_states()) + elif text.startswith("TRANSITION "): + result["transitions"].extend(self.parse_transitions()) + elif text.startswith("TEMPLATE "): + self.parse_template() + elif text.startswith("SCOPE "): + self.parse_scope(result) + elif text.startswith("ASSERT "): + item = self.take() + result["assertions"].append(parse_expression(item.text[7:], item.line)) + else: + result["bindings"].append(self.parse_binding()) + self.semantic_checks(result) + validate_ir(result) + return result + + def parse_environment(self) -> dict[str, Any]: + item = self.take() + match = re.fullmatch(r"ENV_FILE (\".*\") (OPTIONAL|REQUIRED)", item.text) + if match: + return {"kind": "env_file", "path": _quoted(match.group(1), item.line), "required": match.group(2) == "REQUIRED"} + match = re.fullmatch(r"VARIABLE (" + SYMBOL + r") TYPE (\w+) FROM ENV (REQUIRED|DEFAULT .+)", item.text) + if match: + value_type, tail = match.group(2), match.group(3) + if value_type not in SCALAR_TYPES: + raise PolicyError("POLICY-SEMANTIC-001", f"unknown scalar type {value_type}", item.line) + default = None + required = tail == "REQUIRED" + if tail.startswith("DEFAULT "): + expression = parse_expression(tail[8:], item.line) + if expression["node"] != "literal": + raise PolicyError("POLICY-SEMANTIC-001", "VARIABLE default must be a literal", item.line) + default = expression["value"] + return {"kind": "variable", "name": match.group(1), "value_type": value_type, "required": required, "default": default} + match = re.fullmatch(r"SECRET (" + SYMBOL + r") TYPE (\w+) FROM ENV REQUIRED REDACT", item.text) + if match: + if match.group(2) not in SCALAR_TYPES: + raise PolicyError("POLICY-SEMANTIC-001", f"unknown scalar type {match.group(2)}", item.line) + return {"kind": "secret", "name": match.group(1), "value_type": match.group(2), "required": True, "redact": True} + raise PolicyError("POLICY-SYNTAX-001", "invalid environment declaration", item.line) + + def parse_binding(self) -> dict[str, Any]: + item = self.take() + match = re.fullmatch(r"(" + SYMBOL + r")\s+(=|IN)\s+(.+)", item.text) + if not match: + raise PolicyError("POLICY-SYNTAX-001", "unknown top-level statement", item.line) + return {"name": match.group(1), "operator": match.group(2), "value": parse_condition(match.group(3), item.line)} + + def parse_states(self) -> list[str]: + item = self.take() + match = re.fullmatch(r"STATES (\[.+\])", item.text) + if not match: + raise PolicyError("POLICY-SYNTAX-001", "invalid STATES list", item.line) + return _parse_symbol_list(match.group(1), item.line) + + def parse_transitions(self, scope_guard: dict[str, Any] | None = None) -> list[dict[str, Any]]: + item = self.take() + list_match = re.fullmatch(r"TRANSITION (\[.+\]) -> (" + SYMBOL + r")(?: WHEN (.+))?", item.text) + if list_match: + origins = _parse_symbol_list(list_match.group(1), item.line) + target = list_match.group(2) + raw_condition = list_match.group(3) + is_list = True + else: + match = re.fullmatch(r"TRANSITION (" + SYMBOL + r") -> (" + SYMBOL + r")(?: WHEN (.+))?", item.text) + if not match: + raise PolicyError("POLICY-SYNTAX-001", "invalid TRANSITION", item.line) + origins = [match.group(1)] + target = match.group(2) + raw_condition = match.group(3) + is_list = False + own_condition = parse_condition(raw_condition, item.line) if raw_condition else None + condition = own_condition + if scope_guard is not None: + condition = ( + scope_guard + if condition is None + else {"node": "binary", "operator": "AND", "left": scope_guard, "right": condition} + ) + edges = [{"from": origin, "to": target, "condition": condition} for origin in origins] + if is_list: + self.state_set_transition_count += len(origins) + else: + self.uncompacted_transitions.append({"from": origins[0], "to": target, "condition": own_condition}) + return edges + + def _apply_template_to_target(self, target: dict[str, Any], action: dict[str, Any], line: int) -> None: + payload = action.get("payload") + if action.get("guard") is not None or not isinstance(payload, dict) or payload.get("node") != "symbol": + raise PolicyError("POLICY-SYNTAX-001", "APPLY_TEMPLATE requires a single template identifier without WHEN", line) + template_id = payload["name"] + template = self.templates.get(template_id) + if template is None: + raise PolicyError("POLICY-SEMANTIC-001", f"unknown template {template_id}", line) + target["actions"].extend(dict(item) for item in template["actions"]) + target["forbidden"].extend(dict(item) for item in template["forbidden"]) + target["assertions"].extend(dict(item) for item in template["assertions"]) + if template["next"] and not target["next"]: + target["next"] = [dict(item) for item in template["next"]] + + def _collect_directives(self, target: dict[str, Any], raw_inline: dict[str, Any] | None = None) -> None: + while self.current is not None: + item = self.current + if item.text.startswith("DO "): + self.take() + action = parse_action(item.text[3:], item.line) + if action["opcode"] == "APPLY_TEMPLATE": + self._apply_template_to_target(target, action, item.line) + else: + target["actions"].append(action) + if raw_inline is not None: + raw_inline["actions"].append(action) + elif item.text.startswith("FORBID "): + self.take() + forbidden = parse_action(item.text[7:], item.line) + target["forbidden"].append(forbidden) + if raw_inline is not None: + raw_inline["forbidden"].append(forbidden) + elif item.text.startswith("ASSERT "): + self.take() + assertion = parse_expression(item.text[7:], item.line) + target["assertions"].append(assertion) + if raw_inline is not None: + raw_inline["assertions"].append(assertion) + elif item.text.startswith("NEXT "): + self.take() + next_targets = parse_next(item.text[5:], item.line) + target["next"] = next_targets + if raw_inline is not None: + raw_inline["next"] = next_targets + break + else: + break + + def parse_template(self) -> None: + header = self.take() + match = re.fullmatch(r"TEMPLATE (" + STABLE_ID + r")", header.text) + if not match: + raise PolicyError("POLICY-SYNTAX-001", "invalid TEMPLATE header", header.line) + template_id = match.group(1) + if template_id in self.templates: + raise PolicyError("POLICY-SEMANTIC-001", f"duplicate template {template_id}", header.line) + bundle: dict[str, Any] = {"actions": [], "forbidden": [], "assertions": [], "next": []} + self._collect_directives(bundle) + if self.current is None or self.current.text != "END": + line = self.current.line if self.current is not None else header.line + raise PolicyError("POLICY-SYNTAX-001", "TEMPLATE requires closing END", line) + self.take() + if not (bundle["actions"] or bundle["forbidden"] or bundle["assertions"] or bundle["next"]): + raise PolicyError("POLICY-SEMANTIC-001", f"empty template {template_id}", header.line) + self.templates[template_id] = bundle + + def parse_scope(self, result: dict[str, Any], outer_guard: dict[str, Any] | None = None) -> None: + header = self.take() + match = re.fullmatch(r"SCOPE WHEN (.+)", header.text) + if not match: + raise PolicyError("POLICY-SYNTAX-001", "invalid SCOPE WHEN header", header.line) + guard = parse_condition(match.group(1), header.line) + if outer_guard is not None: + guard = {"node": "binary", "operator": "AND", "left": outer_guard, "right": guard} + count = 0 + while self.current is not None and self.current.text != "END": + text = self.current.text + if text.startswith("RULE "): + result["rules"].append(self.parse_rule(scope_guard=guard)) + count += 1 + elif text.startswith("TRANSITION "): + result["transitions"].extend(self.parse_transitions(scope_guard=guard)) + count += 1 + elif text.startswith("SCOPE "): + self.parse_scope(result, outer_guard=guard) + count += 1 + else: + raise PolicyError("POLICY-SYNTAX-001", "SCOPE block accepts only RULE, TRANSITION, or nested SCOPE", self.current.line) + if self.current is None or self.current.text != "END": + line = self.current.line if self.current is not None else header.line + raise PolicyError("POLICY-SYNTAX-001", "SCOPE requires closing END", line) + self.take() + if count == 0: + raise PolicyError("POLICY-SEMANTIC-001", "empty SCOPE block", header.line) + + def parse_rule(self, scope_guard: dict[str, Any] | None = None) -> dict[str, Any]: + header = self.take() + match = re.fullmatch(r"RULE (" + STABLE_ID + r")(?: TYPE (REQUIRED|FORBIDDEN))?", header.text) + if not match: + raise PolicyError("POLICY-SYNTAX-001", "invalid RULE header", header.line) + if not self.current or not self.current.text.startswith("WHEN "): + raise PolicyError("POLICY-SYNTAX-001", "RULE requires WHEN", header.line) + condition_item = self.take() + own_condition = parse_condition(condition_item.text[5:], condition_item.line) + condition = own_condition + if scope_guard is not None: + self.scoped_rule_count += 1 + condition = {"node": "binary", "operator": "AND", "left": scope_guard, "right": own_condition} + rule: dict[str, Any] = { + "id": match.group(1), + "type": match.group(2) or "REQUIRED", + "condition": condition, + "actions": [], + "forbidden": [], + "assertions": [], + "next": [], + } + raw_inline: dict[str, Any] = { + "id": match.group(1), + "condition": own_condition, + "actions": [], + "forbidden": [], + "assertions": [], + "next": [], + } + self._collect_directives(rule, raw_inline) + self.uncompacted_rules.append(raw_inline) + return rule + + @staticmethod + def semantic_checks(ir: dict[str, Any]) -> None: + rule_ids = [rule["id"] for rule in ir["rules"]] + if len(rule_ids) != len(set(rule_ids)): + raise PolicyError("POLICY-SEMANTIC-001", "duplicate rule identifier") + if len(ir["states"]) != len(set(ir["states"])): + raise PolicyError("POLICY-SEMANTIC-001", "duplicate state") + binding_names = [item["name"] for item in ir["bindings"]] + if len(binding_names) != len(set(binding_names)): + raise PolicyError("POLICY-SEMANTIC-001", "duplicate binding") + + +def parse(source: str) -> dict[str, Any]: + return PolicyParser(source).parse() + + +def parse_markdown(source: str) -> dict[str, Any]: + return parse(extract_markdown(source)) + + +def _keys(value: dict[str, Any], allowed: set[str], required: set[str], label: str) -> None: + if not isinstance(value, dict): + raise PolicyError("POLICY-SEMANTIC-001", f"{label} must be an object") + unknown = set(value) - allowed + missing = required - set(value) + if unknown or missing: + raise PolicyError("POLICY-SEMANTIC-001", f"closed {label}: unknown={sorted(unknown)}, missing={sorted(missing)}") + + +def validate_expression(value: Any) -> None: + if not isinstance(value, dict) or "node" not in value: + raise PolicyError("POLICY-SEMANTIC-001", "expression must be a typed object") + node = value["node"] + if node == "literal": + _keys(value, {"node", "value"}, {"node", "value"}, "literal") + if isinstance(value["value"], (dict, list)) or value["value"] is None: + raise PolicyError("POLICY-SEMANTIC-001", "invalid literal") + if isinstance(value["value"], float) and not math.isfinite(value["value"]): + raise PolicyError("POLICY-SEMANTIC-001", "literal number must be finite") + elif node == "symbol": + _keys(value, {"node", "name"}, {"node", "name"}, "symbol") + if not isinstance(value["name"], str) or not re.fullmatch( + r"(?:" + SYMBOL + r"|\{" + SYMBOL + r"\})", value["name"] + ) or len(value["name"]) > 160: + raise PolicyError("POLICY-SEMANTIC-001", "invalid symbol") + elif node == "unary": + _keys(value, {"node", "operator", "operand"}, {"node", "operator", "operand"}, "unary") + if value["operator"] not in {"NOT", "-"}: + raise PolicyError("POLICY-SEMANTIC-001", "invalid unary operator") + validate_expression(value["operand"]) + elif node == "binary": + _keys(value, {"node", "operator", "left", "right"}, {"node", "operator", "left", "right"}, "binary") + if value["operator"] not in ExpressionParser.PRECEDENCE: + raise PolicyError("POLICY-SEMANTIC-001", "invalid binary operator") + validate_expression(value["left"]) + validate_expression(value["right"]) + elif node in {"list", "sequence"}: + _keys(value, {"node", "items"}, {"node", "items"}, node) + if ( + not isinstance(value["items"], list) + or len(value["items"]) > 256 + or (node == "sequence" and len(value["items"]) < 2) + ): + raise PolicyError("POLICY-SEMANTIC-001", f"invalid {node}") + for item in value["items"]: + validate_expression(item) + else: + raise PolicyError("POLICY-SEMANTIC-001", f"unknown expression node {node!r}") + + +def validate_ir(ir: Any) -> None: + if not isinstance(ir, dict): + raise PolicyError("POLICY-SEMANTIC-001", "Policy IR must be an object") + root_keys = {"schema", "dialect", "language_version", "document", "environment", "bindings", "rules", "assertions", "states", "transitions"} + _keys(ir, root_keys, root_keys, "Policy IR") + if (ir["schema"], ir["dialect"], ir["language_version"]) != ("wellmanifest.policy/ir/v1", "wellmanifest.policy/v1", "1"): + raise PolicyError("POLICY-SEMANTIC-001", "incompatible Policy IR identity") + collection_limits = { + "environment": 128, + "bindings": 256, + "rules": 4096, + "assertions": 1024, + "states": 256, + "transitions": 1024, + } + for name, limit in collection_limits.items(): + if not isinstance(ir[name], list) or len(ir[name]) > limit: + raise PolicyError("POLICY-SEMANTIC-001", f"invalid {name} collection") + document_keys = {"name", "version", "language", "mode", "purpose", "policy"} + _keys(ir["document"], document_keys, document_keys, "document") + document = ir["document"] + if not isinstance(document["name"], str) or not re.fullmatch(SYMBOL, document["name"]): + raise PolicyError("POLICY-SEMANTIC-001", "invalid document name") + if isinstance(document["version"], bool) or not isinstance(document["version"], int) or document["version"] < 1: + raise PolicyError("POLICY-SEMANTIC-001", "invalid document version") + if document["language"] is not None and ( + not isinstance(document["language"], str) or not re.fullmatch(SYMBOL, document["language"]) + ): + raise PolicyError("POLICY-SEMANTIC-001", "invalid document language") + if document["mode"] not in {"STRICT", "PROCEDURAL"}: + raise PolicyError("POLICY-SEMANTIC-001", "invalid document mode") + for name, limit in (("purpose", 1000), ("policy", 320)): + if document[name] is not None and (not isinstance(document[name], str) or len(document[name]) > limit): + raise PolicyError("POLICY-SEMANTIC-001", f"invalid document {name}") + environment_names: list[str] = [] + for environment in ir["environment"]: + if not isinstance(environment, dict): + raise PolicyError("POLICY-SEMANTIC-001", "environment binding must be an object") + kind = environment.get("kind") + if kind == "env_file": + _keys(environment, {"kind", "path", "required"}, {"kind", "path", "required"}, "env_file") + if not isinstance(environment["path"], str) or not environment["path"] or len(environment["path"]) > 320: + raise PolicyError("POLICY-SEMANTIC-001", "invalid ENV_FILE path") + if not isinstance(environment["required"], bool): + raise PolicyError("POLICY-SEMANTIC-001", "invalid ENV_FILE requirement") + elif kind == "variable": + keys = {"kind", "name", "value_type", "required", "default"} + _keys(environment, keys, keys, "variable") + _validate_environment_name_type(environment) + if not isinstance(environment["required"], bool): + raise PolicyError("POLICY-SEMANTIC-001", "invalid VARIABLE requirement") + if environment["default"] is not None: + validate_expression({"node": "literal", "value": environment["default"]}) + environment_names.append(environment["name"]) + elif kind == "secret": + keys = {"kind", "name", "value_type", "required", "redact"} + _keys(environment, keys, keys, "secret") + _validate_environment_name_type(environment) + if environment["required"] is not True or environment["redact"] is not True: + raise PolicyError("POLICY-SEMANTIC-001", "SECRET must be required and redacted") + environment_names.append(environment["name"]) + else: + raise PolicyError("POLICY-SEMANTIC-001", "unknown environment binding kind") + if len(environment_names) != len(set(environment_names)): + raise PolicyError("POLICY-SEMANTIC-001", "duplicate environment name") + for binding in ir["bindings"]: + _keys(binding, {"name", "operator", "value"}, {"name", "operator", "value"}, "binding") + if not isinstance(binding["name"], str) or not re.fullmatch(SYMBOL, binding["name"]): + raise PolicyError("POLICY-SEMANTIC-001", "invalid binding name") + if binding["operator"] not in {"=", "IN"}: + raise PolicyError("POLICY-SEMANTIC-001", "invalid binding operator") + validate_expression(binding["value"]) + binding_names = [binding["name"] for binding in ir["bindings"]] + if len(binding_names) != len(set(binding_names)): + raise PolicyError("POLICY-SEMANTIC-001", "duplicate binding") + for rule in ir["rules"]: + rule_keys = {"id", "type", "condition", "actions", "forbidden", "assertions", "next"} + _keys(rule, rule_keys, rule_keys, "rule") + if not isinstance(rule["id"], str) or not re.fullmatch(STABLE_ID, rule["id"]): + raise PolicyError("POLICY-SEMANTIC-001", "invalid rule identifier") + if rule["type"] not in {"REQUIRED", "FORBIDDEN"}: + raise PolicyError("POLICY-SEMANTIC-001", "invalid rule type") + for name, limit in (("actions", 128), ("forbidden", 128), ("assertions", 128), ("next", 32)): + if not isinstance(rule[name], list) or len(rule[name]) > limit: + raise PolicyError("POLICY-SEMANTIC-001", f"invalid rule {name}") + validate_expression(rule["condition"]) + for action in rule["actions"] + rule["forbidden"]: + _keys(action, {"kind", "opcode", "payload", "guard"}, {"kind", "opcode", "payload", "guard"}, "action") + if ( + action["kind"] != "action" + or not isinstance(action["opcode"], str) + or not re.fullmatch(r"[A-Z][A-Z0-9_]*", action["opcode"]) + or len(action["opcode"]) > 96 + ): + raise PolicyError("POLICY-SEMANTIC-001", "invalid action identity") + if action["payload"] is not None: + validate_expression(action["payload"]) + if action["guard"] is not None: + validate_expression(action["guard"]) + for assertion in rule["assertions"]: + validate_expression(assertion) + for target in rule["next"]: + _keys(target, {"target", "condition"}, {"target", "condition"}, "next target") + if not isinstance(target["target"], str) or not re.fullmatch(SYMBOL, target["target"]): + raise PolicyError("POLICY-SEMANTIC-001", "invalid NEXT target") + if target["condition"] is not None: + validate_expression(target["condition"]) + if len({rule["id"] for rule in ir["rules"]}) != len(ir["rules"]): + raise PolicyError("POLICY-SEMANTIC-001", "duplicate rule identifier") + for assertion in ir["assertions"]: + validate_expression(assertion) + if any(not isinstance(state, str) or not re.fullmatch(SYMBOL, state) for state in ir["states"]): + raise PolicyError("POLICY-SEMANTIC-001", "invalid state") + if len(set(ir["states"])) != len(ir["states"]): + raise PolicyError("POLICY-SEMANTIC-001", "duplicate state") + states = set(ir["states"]) + for rule in ir["rules"]: + for target in rule["next"]: + if target["target"] not in states: + raise PolicyError("POLICY-SEMANTIC-001", "NEXT target references undeclared state") + transition_edges: list[tuple[str, str]] = [] + for transition in ir["transitions"]: + _keys(transition, {"from", "to", "condition"}, {"from", "to", "condition"}, "transition") + if any(not isinstance(transition[name], str) or not re.fullmatch(SYMBOL, transition[name]) for name in ("from", "to")): + raise PolicyError("POLICY-SEMANTIC-001", "invalid transition state") + if transition["from"] not in states or transition["to"] not in states: + raise PolicyError("POLICY-SEMANTIC-001", "transition references undeclared state") + if transition["condition"] is not None: + validate_expression(transition["condition"]) + transition_edges.append((transition["from"], transition["to"])) + if len(transition_edges) != len(set(transition_edges)): + raise PolicyError("POLICY-SEMANTIC-001", "duplicate transition") + + +def _validate_environment_name_type(environment: dict[str, Any]) -> None: + if not isinstance(environment["name"], str) or not re.fullmatch(SYMBOL, environment["name"]): + raise PolicyError("POLICY-SEMANTIC-001", "invalid environment name") + if environment["value_type"] not in SCALAR_TYPES: + raise PolicyError("POLICY-SEMANTIC-001", "invalid environment scalar type") + + +def validate_candidate(candidate: Any) -> None: + validate_ir(candidate) + for rule in candidate["rules"]: + for action in rule["actions"] + rule["forbidden"]: + if action["opcode"] not in SAFE_CANDIDATE_OPCODES: + raise PolicyError("POLICY-SECURITY-001", f"candidate opcode {action['opcode']} is not proposal-safe") + + +def schema_is_closed(schema: Any) -> bool: + if isinstance(schema, dict): + if schema.get("type") == "object" and schema.get("additionalProperties") is not False: + return False + return all(schema_is_closed(value) for value in schema.values()) + if isinstance(schema, list): + return all(schema_is_closed(value) for value in schema) + return True + + +def _walk_symbols(node: Any, out: set[str]) -> None: + if isinstance(node, dict): + if node.get("node") == "symbol" and isinstance(node.get("name"), str): + out.add(node["name"]) + for value in node.values(): + _walk_symbols(value, out) + elif isinstance(node, list): + for item in node: + _walk_symbols(item, out) + + +def _top_and_conjuncts(node: dict[str, Any]) -> list[dict[str, Any]]: + if node.get("node") == "binary" and node.get("operator") == "AND": + return _top_and_conjuncts(node["left"]) + _top_and_conjuncts(node["right"]) + return [node] + + +def audit_compaction(source: str, *, markdown: bool = False) -> dict[str, Any]: + raw_policy = extract_markdown(source) if markdown else source + parser = PolicyParser(raw_policy) + ir = parser.parse() + findings: list[dict[str, Any]] = [] + saved_lines = 0 + + symbols: set[str] = set() + _walk_symbols(ir["rules"], symbols) + _walk_symbols(ir["assertions"], symbols) + _walk_symbols(ir["bindings"], symbols) + prose_symbols = sorted( + (name for name in symbols if len(name) > 48 or name.count("_") >= 6), + key=lambda item: (-len(item), item), + ) + if prose_symbols: + findings.append( + { + "code": "POLICY-COMPACT-001", + "construct": "SYMBOL_BINDING / OQL_REF", + "count": len(prose_symbols), + "examples": prose_symbols[:10], + "message": "Prose-encoded symbols (>48 chars or >=6 underscores) should be replaced with atomic domain symbols and contract references", + } + ) + + fan_in_groups: dict[tuple[str, str], list[str]] = {} + for transition in parser.uncompacted_transitions: + cond_key = json.dumps(transition["condition"], sort_keys=True) + key = (transition["to"], cond_key) + fan_in_groups.setdefault(key, []).append(transition["from"]) + for (target, _), origins in sorted(fan_in_groups.items()): + if len(origins) >= 3: + delta = len(origins) - 1 + saved_lines += delta + findings.append( + { + "code": "POLICY-COMPACT-002", + "construct": f"TRANSITION [{', '.join(origins)}] -> {target}", + "count": len(origins), + "lines_saved": delta, + "message": f"{len(origins)} transitions into {target} share an identical guard and can collapse into one state-set TRANSITION", + } + ) + + conjunct_rules: dict[str, list[str]] = {} + for rule in parser.uncompacted_rules: + seen_in_rule: set[str] = set() + for conjunct in _top_and_conjuncts(rule["condition"]): + ckey = json.dumps(conjunct, sort_keys=True) + if ckey not in seen_in_rule: + seen_in_rule.add(ckey) + conjunct_rules.setdefault(ckey, []).append(rule["id"]) + for ckey, rule_ids in sorted(conjunct_rules.items(), key=lambda item: (-len(item[1]), item[0])): + if len(rule_ids) >= 3: + delta = len(rule_ids) - 1 + saved_lines += delta + findings.append( + { + "code": "POLICY-COMPACT-003", + "construct": "SCOPE WHEN ... END", + "count": len(rule_ids), + "lines_saved": delta, + "rules": rule_ids, + "guard": json.loads(ckey), + "message": f"Guard predicate is repeated across {len(rule_ids)} rules and can be factored into SCOPE WHEN", + } + ) + + directive_support: dict[str, list[str]] = {} + for rule in parser.uncompacted_rules: + rule_directives: set[str] = set() + for act in rule["actions"]: + rule_directives.add("DO:" + json.dumps(act, sort_keys=True)) + for fbd in rule["forbidden"]: + rule_directives.add("FORBID:" + json.dumps(fbd, sort_keys=True)) + for asrt in rule["assertions"]: + rule_directives.add("ASSERT:" + json.dumps(asrt, sort_keys=True)) + if rule["next"]: + rule_directives.add("NEXT:" + json.dumps(rule["next"], sort_keys=True)) + for dkey in rule_directives: + directive_support.setdefault(dkey, []).append(rule["id"]) + + support_bundles: dict[tuple[str, ...], list[str]] = {} + for dkey, r_ids in directive_support.items(): + if len(r_ids) >= 2: + support_bundles.setdefault(tuple(r_ids), []).append(dkey) + + for r_tuple, dkeys in sorted(support_bundles.items(), key=lambda item: (-len(item[0]) * len(item[1]), item[0])): + if len(dkeys) >= 2: + dcount = len(dkeys) + rcount = len(r_tuple) + delta = (rcount - 1) * (dcount - 1) + saved_lines += max(0, delta) + findings.append( + { + "code": "POLICY-COMPACT-004", + "construct": "TEMPLATE ... END + DO APPLY_TEMPLATE ", + "count": rcount, + "directives_per_rule": dcount, + "lines_saved": max(0, delta), + "rules": list(r_tuple), + "message": f"Shared {dcount}-directive bundle across {rcount} rules can be extracted into a TEMPLATE", + } + ) + + return { + "schema": "wellmanifest.policy/compaction-audit/v1", + "document": ir["document"]["name"], + "stats": { + "rules": len(ir["rules"]), + "states": len(ir["states"]), + "transitions": len(ir["transitions"]), + "templates_used": len(parser.templates), + "scoped_rules": parser.scoped_rule_count, + "state_set_transitions": parser.state_set_transition_count, + "findings": len(findings), + "estimated_lines_saved": saved_lines, + }, + "findings": findings, + } + + +def self_test() -> None: + valid_path = ROOT / "examples/valid/contributing.policy" + invalid_path = ROOT / "examples/invalid/shell-injection.policy" + ir = parse(valid_path.read_text(encoding="utf-8")) + assert ir["document"]["version"] == 13 + assert ir["language_version"] == "1" + assert ir["rules"][0]["condition"]["node"] == "binary" + assert ir["rules"][0]["actions"][0]["payload"]["node"] == "symbol" + assert "text" not in json.dumps(ir, sort_keys=True) + schema = json.loads((ROOT / "schemas/policy-ir.schema.json").read_text(encoding="utf-8")) + assert schema_is_closed(schema) + grammar = (ROOT / "spec/policy-dsl-candidate.v1.gbnf").read_text(encoding="utf-8") + for forbidden in ('\"EXECUTE\"', '\"RUN\"', '\"approval_evidence\":', '\"execution_envelope\":', '\"credentials\":'): + assert forbidden not in grammar + try: + parse(invalid_path.read_text(encoding="utf-8")) + except PolicyError as error: + assert error.code == "POLICY-SECURITY-001" + else: + raise AssertionError("invalid fixture was accepted") + markdown = "before\n```dsl\n" + valid_path.read_text(encoding="utf-8") + "```\n```bash\nDO RUN evil\n```\n" + assert parse_markdown(markdown)["document"]["name"] == "CONTRIBUTING" + try: + parse_markdown(markdown + "```dsl\nDONE WHEN\n TESTS_PASSED\n```\n") + except PolicyError as error: + assert error.code == "POLICY-SYNTAX-001" + else: + raise AssertionError("unclassified dsl fence was silently ignored") + print("POLICY-CONFORMANCE-PASS") + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + validate_command = subparsers.add_parser("validate") + validate_command.add_argument("path", type=Path) + markdown_command = subparsers.add_parser("validate-markdown") + markdown_command.add_argument("path", type=Path) + ir_command = subparsers.add_parser("ir") + ir_command.add_argument("path", type=Path) + ir_command.add_argument("--check-schema", action="store_true") + candidate_command = subparsers.add_parser("validate-candidate") + candidate_command.add_argument("path", type=Path) + audit_command = subparsers.add_parser("audit") + audit_command.add_argument("path", type=Path) + audit_command.add_argument("--markdown", action="store_true") + subparsers.add_parser("self-test") + args = parser.parse_args(list(argv) if argv is not None else None) + try: + if args.command == "self-test": + self_test() + elif args.command == "validate-candidate": + validate_candidate(json.loads(args.path.read_text(encoding="utf-8"))) + print("POLICY-CANDIDATE-PASS") + elif args.command == "validate-markdown": + parse_markdown(args.path.read_text(encoding="utf-8")) + print("POLICY-MARKDOWN-PASS") + elif args.command == "audit": + raw = args.path.read_text(encoding="utf-8") + is_md = args.markdown or args.path.suffix.lower() == ".md" + report = audit_compaction(raw, markdown=is_md) + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + else: + ir = parse(args.path.read_text(encoding="utf-8")) + if args.command == "ir": + if args.check_schema: + schema = json.loads((ROOT / "schemas/policy-ir.schema.json").read_text(encoding="utf-8")) + if not schema_is_closed(schema): + raise PolicyError("POLICY-SEMANTIC-001", "Policy IR Schema is not closed") + validate_ir(ir) + print(json.dumps(ir, ensure_ascii=False, indent=2, sort_keys=True)) + else: + print("POLICY-VALIDATION-PASS") + except (OSError, json.JSONDecodeError, PolicyError, AssertionError) as error: + print(error, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/precommit_standard_update.py b/.governance/precommit_standard_update.py new file mode 100755 index 0000000..922b39d --- /dev/null +++ b/.governance/precommit_standard_update.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Delegate pre-commit standard freshness to Goal's trusted adopter.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + + +DIAGNOSTIC = "GOV-STANDARD-UPDATE-001" +DEFAULT_UPDATE_POLICY = { + "enabled": True, + "trigger": "pre-commit", + "action": "prepare-and-abort", + "executor": "goal", +} + + +def _refuse(message: str, *, returncode: int = 2) -> int: + print(f"{DIAGNOSTIC}: {message}", file=sys.stderr) + print( + " Install compatible Goal or repair the active standard-adoption " + "ticket; never bypass the hook.", + file=sys.stderr, + ) + return returncode + + +def _load_update_policy(path: Path) -> dict[str, object]: + try: + adoption = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError("standard-adoption.json is unreadable or invalid") from error + if not isinstance(adoption, dict): + raise ValueError("standard-adoption.json must contain an object") + updates = adoption.get("updates") + if updates is None: + return dict(DEFAULT_UPDATE_POLICY) + if not isinstance(updates, dict) or set(updates) != set(DEFAULT_UPDATE_POLICY): + raise ValueError("standard update policy has missing or unknown fields") + if ( + not isinstance(updates.get("enabled"), bool) + or updates.get("trigger") != "pre-commit" + or updates.get("action") != "prepare-and-abort" + or updates.get("executor") not in {"goal", "koru-goal"} + ): + raise ValueError("standard update policy contains unsupported values") + return updates + + +def run( + root: Path, + ticket: str, + *, + goal_executable: str = "goal", + koru_executable: str = "koru", +) -> int: + """Run the Goal-owned protocol when this repository has a standard pin.""" + target = root.resolve() + adoption_path = target / ".governance" / "standard-adoption.json" + if not adoption_path.is_file(): + return 0 + try: + policy = _load_update_policy(adoption_path) + except ValueError as error: + return _refuse(str(error)) + if not policy["enabled"]: + return 0 + + goal = shutil.which(goal_executable) + if goal is None: + return _refuse("Goal is unavailable, so standard freshness cannot be verified") + goal_arguments = [ + "governance", + "adopt", + "--latest", + "--pre-commit", + "--target-root", + str(target), + "--ticket", + ticket, + ] + if policy["executor"] == "koru-goal": + koru = shutil.which(koru_executable) + if koru is None: + return _refuse( + "Koru is the configured standard update executor but is unavailable" + ) + command = [ + koru, + "goal", + "--project", + str(target), + "--goal-executable", + goal, + "--", + *goal_arguments, + ] + else: + command = [goal, *goal_arguments] + try: + completed = subprocess.run(command, text=True, capture_output=True, check=False) + except OSError: + return _refuse("Goal could not execute the standard update protocol") + if completed.stdout: + print(completed.stdout, end="" if completed.stdout.endswith("\n") else "\n") + if completed.stderr: + print( + completed.stderr, + end="" if completed.stderr.endswith("\n") else "\n", + file=sys.stderr, + ) + if completed.returncode != 0: + return _refuse( + "Goal refused or prepared a standard update; review its evidence before retrying", + returncode=completed.returncode, + ) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--ticket", required=True) + parser.add_argument("--goal-executable", default="goal") + parser.add_argument("--koru-executable", default="koru") + args = parser.parse_args() + return run( + args.root, + args.ticket, + goal_executable=args.goal_executable, + koru_executable=args.koru_executable, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/prune_merged_worktrees.py b/.governance/prune_merged_worktrees.py new file mode 100755 index 0000000..ff09387 --- /dev/null +++ b/.governance/prune_merged_worktrees.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Prune clean integrated checkouts while preserving changed or declined refs. + +The caller must separately verify ownership, released writers and cleanup +permission. Git removal releases only the exact checkout registration. Branch +cleanup verifies the integration target and expected branch tip atomically; +this helper never forces deletion or globally prunes other registrations. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + + +def git_cmd(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(cwd), *args], + capture_output=True, + text=True, + check=False, + ) + + +def get_worktrees(repo: Path) -> list[dict[str, str]]: + res = git_cmd(repo, "worktree", "list", "--porcelain") + if res.returncode != 0: + raise RuntimeError(f"git worktree list failed: {res.stderr}") + + worktrees: list[dict[str, str]] = [] + current: dict[str, str] = {} + for line in res.stdout.splitlines(): + line = line.strip() + if not line: + if current: + worktrees.append(current) + current = {} + continue + parts = line.split(" ", 1) + key = parts[0] + val = parts[1] if len(parts) > 1 else "" + current[key] = val + if current: + worktrees.append(current) + return worktrees + + +def is_worktree_clean(path: Path) -> bool: + # Ignored files may contain unique data, so retain their checkout too. + res = git_cmd(path, "status", "--porcelain", "--untracked-files=all", "--ignored") + return res.returncode == 0 and not res.stdout.strip() + + +def is_ancestor(repo: Path, commit: str, target: str) -> bool: + res = git_cmd(repo, "merge-base", "--is-ancestor", commit, target) + return res.returncode == 0 + + +def _commit(repo: Path, ref: str) -> str | None: + result = git_cmd(repo, "rev-parse", "--verify", ref + "^{commit}") + value = result.stdout.strip() + if result.returncode == 0 and re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", value): + return value + return None + + +def _target(repo: Path, name: str) -> tuple[str, str, str]: + if name.startswith(("refs/heads/", "refs/remotes/")): + candidates = [(name, name)] + elif name.startswith("origin/"): + candidates = [(name, "refs/remotes/" + name)] + else: + candidates = [("origin/" + name, "refs/remotes/origin/" + name), + (name, "refs/heads/" + name)] + for display, full in candidates: + if full.startswith("refs/remotes/") and "/" not in full.removeprefix("refs/remotes/"): + continue + if git_cmd(repo, "check-ref-format", full).returncode != 0: + continue + if git_cmd(repo, "symbolic-ref", "--quiet", full).returncode == 0: + raise ValueError("Target must identify a direct branch reference, not a symbolic alias") + sha = _commit(repo, full) + if sha is not None: + return display, full, sha + raise ValueError(f"Target branch '{name}' not found in {repo}") + + +def _checkout_matches(path: Path, head: str, branch: str) -> bool: + if _commit(path, "HEAD") != head: + return False + symbolic = git_cmd(path, "symbolic-ref", "--quiet", "HEAD") + if symbolic.returncode not in (0, 1): + return False + return (symbolic.stdout.strip() if symbolic.returncode == 0 else "") == branch + + +def _delete_exact_branch( + repo: Path, branch: str, head: str, target: str, target_sha: str, +) -> subprocess.CompletedProcess[str]: + # Prepare locks both exact names and verifies their expected OIDs. Inspect + # their direct/symbolic type while those locks are held, before committing. + command = ["git", "-C", str(repo), "update-ref", "--no-deref", "--stdin"] + transaction = (f"start\nverify {target} {target_sha}\n" + f"delete {branch} {head}\nprepare\n") + with tempfile.TemporaryFile(mode="w+t", encoding="utf-8", errors="replace") as errors: + try: + process = subprocess.Popen( + command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=errors, text=True, encoding="utf-8", errors="replace", + ) + except OSError as error: + return subprocess.CompletedProcess(command, 1, "", str(error)) + prefix = "" + output = "" + declined = None + try: + process.stdin.write(transaction) + process.stdin.flush() + prefix = process.stdout.readline() + process.stdout.readline() + if prefix != "start: ok\nprepare: ok\n": + declined = "reference transaction preparation failed" + else: + for ref in (target, branch): + if git_cmd(repo, "symbolic-ref", "--quiet", ref).returncode != 1: + declined = "reference became symbolic or could not be verified under lock" + break + if not declined: + try: + if any(item.get("branch") == branch for item in get_worktrees(repo)): + declined = "branch remains checked out elsewhere" + except RuntimeError as error: + declined = f"could not verify other checkout registrations: {error}" + if process.poll() is None: + process.stdin.write("abort\n" if declined else "commit\n") + process.stdin.flush() + output, _ = process.communicate() + except (OSError, ValueError) as error: + declined = f"reference transaction outcome unconfirmed: {error}" + finally: + if process.poll() is None or (process.stdout and not process.stdout.closed): + if process.poll() is None: + try: + process.stdin.write("abort\n") + process.stdin.flush() + except (OSError, ValueError): + pass + try: + remaining, _ = process.communicate(timeout=5) + output += remaining or "" + except subprocess.TimeoutExpired: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + for stream in (process.stdin, process.stdout): + if stream and not stream.closed: + stream.close() + declined = "reference transaction interrupted; reconcile refs before retry" + errors.seek(0) + detail = errors.read() + if declined: + return subprocess.CompletedProcess(command, 1, prefix + output, declined + ": " + detail) + if process.returncode == 0 and "commit: ok\n" not in output: + return subprocess.CompletedProcess(command, 1, prefix + output, "commit outcome unconfirmed: " + detail) + return subprocess.CompletedProcess(command, process.returncode, prefix + output, detail) + + +def prune_merged_worktrees( + repo: Path, target_branch: str = "main", dry_run: bool = False +) -> dict[str, Any]: + target_ref, target_full, target_sha = _target(repo, target_branch) + wts = get_worktrees(repo) + if not wts: + return {"pruned": [], "skipped": []} + target_name = (target_full.removeprefix("refs/heads/") + if target_full.startswith("refs/heads/") + else target_full.removeprefix("refs/remotes/").split("/", 1)[1]) + pruned: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + for wt in wts[1:]: + path = Path(wt.get("worktree", "")) + head = wt.get("HEAD", "") + branch = wt.get("branch", "") + name = branch.removeprefix("refs/heads/") if branch else None + + def skip(reason: str, **extra: Any) -> None: + skipped.append({"path": str(path), "branch": name, "reason": reason, **extra}) + + if not path.exists(): + skip("directory missing") + continue + if branch and (not branch.startswith("refs/heads/") + or git_cmd(repo, "check-ref-format", branch).returncode != 0): + skip("invalid branch reference") + continue + if name == target_name or branch == target_full: + skip("target branch checkout") + continue + if not is_worktree_clean(path): + skip("dirty (uncommitted, untracked or ignored data)") + continue + if not is_ancestor(repo, head, target_sha): + skip(f"not merged into {target_ref}", head=head) + continue + if (_commit(repo, target_full) != target_sha + or not _checkout_matches(path, head, branch) + or (branch and _commit(repo, branch) != head)): + skip("checkout, branch or target changed since observation") + continue + if dry_run: + pruned.append({"path": str(path), "branch": name, "action": "would_remove"}) + continue + # Git also refuses removal if tracked/untracked data appeared after + # inspection; never turn its refusal into a force removal. + removed = git_cmd(repo, "worktree", "remove", str(path)) + if removed.returncode != 0: + skip(f"remove failed: {removed.stderr.strip()}") + continue + if branch: + deleted = _delete_exact_branch(repo, branch, head, target_full, target_sha) + if deleted.returncode != 0: + skip(f"branch cleanup declined or unconfirmed: {deleted.stderr.strip()}") + pruned.append({"path": str(path), "branch": name, "action": "removed"}) + return {"target": target_ref, "pruned": pruned, "skipped": skipped} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Prune linked worktrees that are clean and already merged." + ) + parser.add_argument( + "--repo", + type=Path, + default=Path.cwd(), + help="Path to repository (default: current directory)", + ) + parser.add_argument( + "--target-branch", default="main", help="Target branch to check ancestry against (default: main)" + ) + parser.add_argument( + "--dry-run", action="store_true", help="Report what would be pruned without changing anything" + ) + parser.add_argument("--json", action="store_true", help="Output JSON format") + args = parser.parse_args(argv) + + try: + report = prune_merged_worktrees( + args.repo.resolve(), target_branch=args.target_branch, dry_run=args.dry_run + ) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + if args.json: + print(json.dumps(report, indent=2)) + else: + print(f"Target branch: {report['target']}") + print(f"Pruned ({len(report['pruned'])}):") + for item in report["pruned"]: + print(f" - {item['path']} ({item.get('branch', 'detached')}) -> {item['action']}") + print(f"Skipped ({len(report['skipped'])}):") + for item in report["skipped"]: + print(f" - {item['path']} ({item.get('branch', 'detached')}): {item['reason']}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.governance/remediation-intent.schema.json b/.governance/remediation-intent.schema.json new file mode 100644 index 0000000..b4d5c6a --- /dev/null +++ b/.governance/remediation-intent.schema.json @@ -0,0 +1,458 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/new-project-remediation-intent-v1.json", + "title": "Target-owned diagnostic remediation intent DSL", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "intentId", + "ticket", + "repository", + "ownerRoute", + "status", + "source", + "objective", + "scope", + "findings", + "actions", + "verifications", + "acceptanceCriteria", + "llmGuidance", + "todo2code" + ], + "properties": { + "schema": {"const": "new-project.remediation-intent/v1"}, + "intentId": {"type": "string", "pattern": "^RI-[A-Z0-9][A-Z0-9-]*$"}, + "ticket": {"type": "string", "pattern": "^ticket-[0-9]{3,}$"}, + "repository": {"$ref": "#/$defs/repository"}, + "ownerRoute": {"$ref": "#/$defs/nonempty"}, + "status": {"enum": ["DRAFT", "READY", "ANALYZED"]}, + "source": {"$ref": "#/$defs/source"}, + "objective": {"$ref": "#/$defs/objective"}, + "scope": {"$ref": "#/$defs/scope"}, + "findings": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/finding"} + }, + "actions": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/action"} + }, + "verifications": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/verification"} + }, + "acceptanceCriteria": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/acceptanceCriterion"} + }, + "llmGuidance": {"$ref": "#/$defs/llmGuidance"}, + "todo2code": {"$ref": "#/$defs/todo2code"}, + "advisoryAnalysis": {"$ref": "#/$defs/advisoryAnalysis"} + }, + "allOf": [ + { + "if": { + "properties": {"status": {"const": "ANALYZED"}}, + "required": ["status"] + }, + "then": {"required": ["advisoryAnalysis"]} + } + ], + "$defs": { + "nonempty": {"type": "string", "minLength": 1}, + "digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!unresolved:)(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" + }, + "pathOrUnresolved": { + "oneOf": [ + {"$ref": "#/$defs/path"}, + {"const": "unresolved:agent"} + ] + }, + "stringList": { + "type": "array", + "items": {"$ref": "#/$defs/nonempty"}, + "uniqueItems": true + }, + "nonemptyStringList": { + "type": "array", + "items": {"$ref": "#/$defs/nonempty"}, + "minItems": 1, + "uniqueItems": true + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["producer", "observedAt", "reportDigest"], + "properties": { + "producer": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": {"$ref": "#/$defs/nonempty"}, + "version": {"$ref": "#/$defs/nonempty"} + } + }, + "observedAt": {"type": "string", "format": "date-time"}, + "reportDigest": { + "oneOf": [ + {"$ref": "#/$defs/digest"}, + {"const": "unresolved:agent"} + ] + } + } + }, + "objective": { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "nonGoals", "constraints"], + "properties": { + "outcome": {"$ref": "#/$defs/nonempty"}, + "nonGoals": {"$ref": "#/$defs/nonemptyStringList"}, + "constraints": {"$ref": "#/$defs/nonemptyStringList"} + } + }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": ["allowedPaths", "forbiddenPaths", "preservePaths"], + "properties": { + "allowedPaths": { + "type": "array", + "items": {"$ref": "#/$defs/pathOrUnresolved"}, + "minItems": 1, + "uniqueItems": true + }, + "forbiddenPaths": { + "type": "array", + "items": {"$ref": "#/$defs/path"}, + "uniqueItems": true + }, + "preservePaths": { + "type": "array", + "items": {"$ref": "#/$defs/path"}, + "uniqueItems": true + } + } + }, + "diagnosticExpectation": { + "type": "object", + "additionalProperties": false, + "required": ["code", "current", "required"], + "properties": { + "code": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*(?:[_-][A-Z0-9]+)*$" + }, + "current": {"enum": ["EMITTED", "MISSING", "FALSE_POSITIVE", "DRIFT"]}, + "required": {"enum": ["EMIT", "SUPPRESS", "REFINE", "PRESERVE"]} + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["ref", "observation"], + "properties": { + "ref": {"$ref": "#/$defs/nonempty"}, + "observation": {"$ref": "#/$defs/nonempty"} + } + }, + "applicability": { + "type": "object", + "additionalProperties": false, + "required": ["requiredSignals", "excludedSignals", "unknownOutcome"], + "properties": { + "requiredSignals": {"$ref": "#/$defs/nonemptyStringList"}, + "excludedSignals": {"$ref": "#/$defs/stringList"}, + "unknownOutcome": {"enum": ["BLOCK", "REPORT"]} + } + }, + "finding": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "category", + "status", + "priority", + "summary", + "diagnostic", + "evidence", + "applicability", + "desiredOutcome", + "affectedPaths", + "dependsOn", + "acceptanceCriteria" + ], + "properties": { + "id": {"type": "string", "pattern": "^F-[A-Z0-9][A-Z0-9-]*$"}, + "category": { + "enum": [ + "FALSE_POSITIVE", + "SILENT_OMISSION", + "AMBIGUOUS_HEURISTIC", + "CONTRACT_DRIFT", + "MISSING_INVENTORY", + "STATE_RISK", + "OTHER" + ] + }, + "status": {"enum": ["CONFIRMED", "PROPOSED", "DEFERRED"]}, + "priority": {"enum": ["P0", "P1", "P2", "P3"]}, + "summary": {"$ref": "#/$defs/nonempty"}, + "diagnostic": {"$ref": "#/$defs/diagnosticExpectation"}, + "evidence": { + "type": "array", + "items": {"$ref": "#/$defs/evidence"}, + "minItems": 1 + }, + "applicability": {"$ref": "#/$defs/applicability"}, + "desiredOutcome": {"$ref": "#/$defs/nonempty"}, + "affectedPaths": { + "type": "array", + "items": {"$ref": "#/$defs/pathOrUnresolved"}, + "minItems": 1, + "uniqueItems": true + }, + "dependsOn": { + "type": "array", + "items": {"type": "string", "pattern": "^F-[A-Z0-9][A-Z0-9-]*$"}, + "uniqueItems": true + }, + "acceptanceCriteria": { + "type": "array", + "items": {"type": "string", "pattern": "^AC-[0-9]+$"}, + "minItems": 1, + "uniqueItems": true + } + } + }, + "risk": { + "type": "object", + "additionalProperties": false, + "required": ["level", "authorization", "automation", "preservesUserData"], + "properties": { + "level": {"enum": ["READ_ONLY", "REVERSIBLE_WRITE", "DESTRUCTIVE"]}, + "authorization": { + "enum": ["NOT_APPLICABLE", "SESSION_EXECUTION_AUTHORIZATION", "EXPLICIT_HUMAN"] + }, + "automation": {"enum": ["ALLOWED", "PROHIBITED"]}, + "preservesUserData": {"type": "boolean"} + } + }, + "action": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "findingIds", + "operation", + "description", + "paths", + "dependsOn", + "verificationIds", + "risk" + ], + "properties": { + "id": {"type": "string", "pattern": "^A-[A-Z0-9][A-Z0-9-]*$"}, + "findingIds": { + "type": "array", + "items": {"type": "string", "pattern": "^F-[A-Z0-9][A-Z0-9-]*$"}, + "minItems": 1, + "uniqueItems": true + }, + "operation": { + "enum": ["CLASSIFY", "IMPLEMENT", "TEST", "DOCUMENT", "RELEASE", "TRIAGE", "PRESERVE"] + }, + "description": {"$ref": "#/$defs/nonempty"}, + "paths": { + "type": "array", + "items": {"$ref": "#/$defs/pathOrUnresolved"}, + "minItems": 1, + "uniqueItems": true + }, + "dependsOn": { + "type": "array", + "items": {"type": "string", "pattern": "^A-[A-Z0-9][A-Z0-9-]*$"}, + "uniqueItems": true + }, + "verificationIds": { + "type": "array", + "items": {"type": "string", "pattern": "^V-[A-Z0-9][A-Z0-9-]*$"}, + "minItems": 1, + "uniqueItems": true + }, + "risk": {"$ref": "#/$defs/risk"} + } + }, + "verification": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "command", "expected", "deterministic", "covers"], + "properties": { + "id": {"type": "string", "pattern": "^V-[A-Z0-9][A-Z0-9-]*$"}, + "type": {"enum": ["COMMAND", "ASSERTION", "EXTERNAL_EVIDENCE"]}, + "command": { + "oneOf": [ + {"$ref": "#/$defs/nonempty"}, + {"type": "null"} + ] + }, + "expected": {"$ref": "#/$defs/nonempty"}, + "deterministic": {"type": "boolean"}, + "covers": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?:F|A)-[A-Z0-9][A-Z0-9-]*$" + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "acceptanceCriterion": { + "type": "object", + "additionalProperties": false, + "required": ["id", "statement", "findingIds", "verificationIds"], + "properties": { + "id": {"type": "string", "pattern": "^AC-[0-9]+$"}, + "statement": {"$ref": "#/$defs/nonempty"}, + "findingIds": { + "type": "array", + "items": {"type": "string", "pattern": "^F-[A-Z0-9][A-Z0-9-]*$"}, + "minItems": 1, + "uniqueItems": true + }, + "verificationIds": { + "type": "array", + "items": {"type": "string", "pattern": "^V-[A-Z0-9][A-Z0-9-]*$"}, + "minItems": 1, + "uniqueItems": true + } + } + }, + "llmGuidance": { + "type": "object", + "additionalProperties": false, + "required": ["role", "mustPreserve", "forbiddenAssumptions", "planningOrder", "openQuestions"], + "properties": { + "role": {"$ref": "#/$defs/nonempty"}, + "mustPreserve": {"$ref": "#/$defs/nonemptyStringList"}, + "forbiddenAssumptions": {"$ref": "#/$defs/nonemptyStringList"}, + "planningOrder": { + "type": "array", + "items": {"type": "string", "pattern": "^A-[A-Z0-9][A-Z0-9-]*$"}, + "minItems": 1, + "uniqueItems": true + }, + "openQuestions": {"$ref": "#/$defs/stringList"} + } + }, + "todo2code": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "taskPath", "todoPath", "planSchema", "requiredDiagnosticCodes"], + "properties": { + "enabled": {"type": "boolean"}, + "taskPath": {"$ref": "#/$defs/path"}, + "todoPath": {"$ref": "#/$defs/path"}, + "planSchema": {"const": "t2c.code-change-plan/v1"}, + "requiredDiagnosticCodes": { + "type": "array", + "items": { + "enum": [ + "AMBIGUOUS_REQUIREMENT", + "HUMAN_AGENT_CONFLICT", + "HUMAN_COMMUNICATION_CONFLICT", + "PLANNED_NOT_IMPLEMENTED" + ] + }, + "minItems": 4, + "uniqueItems": true + } + } + }, + "analysisFinding": { + "type": "object", + "additionalProperties": false, + "required": ["code", "severity", "message", "references", "llmHint"], + "properties": { + "code": { + "enum": [ + "T2C_AMBIGUOUS_INTENT", + "T2C_CONFLICT", + "T2C_CRITERION_GAP", + "T2C_PLAN_GAP", + "T2C_PRIORITY_DRIFT", + "T2C_SCOPE_EXPANSION", + "T2C_UNAUTHORIZED_DELETION" + ] + }, + "severity": {"enum": ["BLOCKING", "REVIEW", "INFO"]}, + "message": {"$ref": "#/$defs/nonempty"}, + "references": {"$ref": "#/$defs/nonemptyStringList"}, + "llmHint": {"$ref": "#/$defs/nonempty"} + } + }, + "advisoryAnalysis": { + "type": "object", + "additionalProperties": false, + "required": [ + "authority", + "producer", + "analyzedAt", + "intentDigest", + "graphDigest", + "diagnosticsDigest", + "plansDigest", + "projectionRecordIds", + "planIds", + "findings", + "llmHints" + ], + "properties": { + "authority": {"const": "ADVISORY"}, + "producer": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version", "mode"], + "properties": { + "name": {"const": "todo2code"}, + "version": {"$ref": "#/$defs/nonempty"}, + "mode": {"const": "deterministic"} + } + }, + "analyzedAt": {"type": "string", "format": "date-time"}, + "intentDigest": {"$ref": "#/$defs/digest"}, + "graphDigest": {"$ref": "#/$defs/digest"}, + "diagnosticsDigest": {"$ref": "#/$defs/digest"}, + "plansDigest": {"$ref": "#/$defs/digest"}, + "projectionRecordIds": { + "$ref": "#/$defs/stringList", + "minItems": 1 + }, + "planIds": {"$ref": "#/$defs/stringList"}, + "findings": { + "type": "array", + "items": {"$ref": "#/$defs/analysisFinding"} + }, + "llmHints": {"$ref": "#/$defs/stringList"} + } + } + } +} diff --git a/.governance/remediation-intent.template.dsl.json b/.governance/remediation-intent.template.dsl.json new file mode 100644 index 0000000..ad06b4d --- /dev/null +++ b/.governance/remediation-intent.template.dsl.json @@ -0,0 +1,144 @@ +{ + "schema": "new-project.remediation-intent/v1", + "intentId": "RI-UNRESOLVED", + "ticket": "ticket-000", + "repository": "owner/repository", + "ownerRoute": "unresolved:human", + "status": "DRAFT", + "source": { + "producer": { + "name": "diagnostic-tool", + "version": "unresolved:agent" + }, + "observedAt": "1970-01-01T00:00:00Z", + "reportDigest": "unresolved:agent" + }, + "objective": { + "outcome": "Replace this draft outcome with one observable desired state.", + "nonGoals": [ + "Do not expand scope beyond the owning ticket." + ], + "constraints": [ + "Preserve user-owned and unclassified data." + ] + }, + "scope": { + "allowedPaths": [ + "unresolved:agent" + ], + "forbiddenPaths": [ + ".env" + ], + "preservePaths": [] + }, + "findings": [ + { + "id": "F-UNRESOLVED", + "category": "OTHER", + "status": "PROPOSED", + "priority": "P2", + "summary": "Replace this draft with an evidence-backed finding.", + "diagnostic": { + "code": "UNRESOLVED_FINDING", + "current": "MISSING", + "required": "EMIT" + }, + "evidence": [ + { + "ref": "unresolved:agent", + "observation": "Capture the exact report path, code and observed state." + } + ], + "applicability": { + "requiredSignals": [ + "Define the signal that proves this finding applies." + ], + "excludedSignals": [], + "unknownOutcome": "BLOCK" + }, + "desiredOutcome": "Define a deterministic postcondition.", + "affectedPaths": [ + "unresolved:agent" + ], + "dependsOn": [], + "acceptanceCriteria": [ + "AC-01" + ] + } + ], + "actions": [ + { + "id": "A-UNRESOLVED", + "findingIds": [ + "F-UNRESOLVED" + ], + "operation": "CLASSIFY", + "description": "Resolve the affected path and implementation action.", + "paths": [ + "unresolved:agent" + ], + "dependsOn": [], + "verificationIds": [ + "V-UNRESOLVED" + ], + "risk": { + "level": "READ_ONLY", + "authorization": "NOT_APPLICABLE", + "automation": "ALLOWED", + "preservesUserData": true + } + } + ], + "verifications": [ + { + "id": "V-UNRESOLVED", + "type": "ASSERTION", + "command": null, + "expected": "Replace with a deterministic verification.", + "deterministic": true, + "covers": [ + "F-UNRESOLVED", + "A-UNRESOLVED" + ] + } + ], + "acceptanceCriteria": [ + { + "id": "AC-01", + "statement": "The finding has a bounded implementation path and deterministic verification.", + "findingIds": [ + "F-UNRESOLVED" + ], + "verificationIds": [ + "V-UNRESOLVED" + ] + } + ], + "llmGuidance": { + "role": "Plan a bounded refactoring; do not implement or approve it.", + "mustPreserve": [ + "Accepted ticket scope and user-owned data." + ], + "forbiddenAssumptions": [ + "Do not infer missing paths, ownership or authorization." + ], + "planningOrder": [ + "A-UNRESOLVED" + ], + "openQuestions": [ + "Which exact source and test paths implement this diagnostic?" + ] + }, + "todo2code": { + "enabled": true, + "taskPath": "project/ticket-000/REMEDIATION.task.md", + "todoPath": "project/ticket-000/REMEDIATION.todo.md", + "planSchema": "t2c.code-change-plan/v1", + "requiredDiagnosticCodes": [ + "AMBIGUOUS_REQUIREMENT", + "HUMAN_AGENT_CONFLICT", + "HUMAN_COMMUNICATION_CONFLICT", + "PLANNED_NOT_IMPLEMENTED" + ] + } +} diff --git a/.governance/remediation_intent.py b/.governance/remediation_intent.py new file mode 100755 index 0000000..9d68a13 --- /dev/null +++ b/.governance/remediation_intent.py @@ -0,0 +1,2000 @@ +#!/usr/bin/env python3 +"""Validate and project target-owned diagnostic remediation intent DSL files.""" + +from __future__ import annotations + +import argparse +from copy import deepcopy +from datetime import datetime, timezone +from fnmatch import fnmatchcase +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import sys +import tempfile +from typing import Any + + +INTENT_SCHEMA = "new-project.remediation-intent/v1" +VALIDATION_SCHEMA = "new-project.remediation-validation/v1" +T2C_DIAGNOSTICS_SCHEMA = "t2c.diagnostics/v1" +T2C_PLAN_SET_SCHEMA = "t2c.code-change-plan-set/v1" +T2C_PLAN_SCHEMA = "t2c.code-change-plan/v1" +T2C_GRAPH_SCHEMA = "t2c.graph/v1" +MALFORMED_CODE = "GOV-REMEDIATION-001" +T2C_CODE = "GOV-REMEDIATION-002" +STALE_CODE = "GOV-REMEDIATION-003" +PROJECTION_CODE = "GOV-REMEDIATION-004" + +INTENT_ID = re.compile(r"RI-[A-Z0-9][A-Z0-9-]*") +TICKET_ID = re.compile(r"ticket-[0-9]{3,}") +REPOSITORY = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") +FINDING_ID = re.compile(r"F-[A-Z0-9][A-Z0-9-]*") +ACTION_ID = re.compile(r"A-[A-Z0-9][A-Z0-9-]*") +VERIFICATION_ID = re.compile(r"V-[A-Z0-9][A-Z0-9-]*") +CRITERION_ID = re.compile(r"AC-[0-9]+") +DIAGNOSTIC_CODE = re.compile(r"[A-Z][A-Z0-9]*(?:[_-][A-Z0-9]+)*") +DIGEST = re.compile(r"[0-9a-f]{64}") +SOURCE_DATE_TIME = re.compile( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-9]{2}" + r"(?:\.[0-9]+)?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})" +) + +FINDING_CATEGORIES = { + "FALSE_POSITIVE", + "SILENT_OMISSION", + "AMBIGUOUS_HEURISTIC", + "CONTRACT_DRIFT", + "MISSING_INVENTORY", + "STATE_RISK", + "OTHER", +} +FINDING_STATUSES = {"CONFIRMED", "PROPOSED", "DEFERRED"} +PRIORITIES = {"P0", "P1", "P2", "P3"} +DIAGNOSTIC_STATES = {"EMITTED", "MISSING", "FALSE_POSITIVE", "DRIFT"} +DIAGNOSTIC_OUTCOMES = {"EMIT", "SUPPRESS", "REFINE", "PRESERVE"} +OPERATIONS = { + "CLASSIFY", + "IMPLEMENT", + "TEST", + "DOCUMENT", + "RELEASE", + "TRIAGE", + "PRESERVE", +} +RISK_LEVELS = {"READ_ONLY", "REVERSIBLE_WRITE", "DESTRUCTIVE"} +AUTHORIZATIONS = { + "NOT_APPLICABLE", + "SESSION_EXECUTION_AUTHORIZATION", + "EXPLICIT_HUMAN", +} +AUTOMATION_VALUES = {"ALLOWED", "PROHIBITED"} +VERIFICATION_TYPES = {"COMMAND", "ASSERTION", "EXTERNAL_EVIDENCE"} +ANALYSIS_CODES = { + "T2C_AMBIGUOUS_INTENT", + "T2C_CONFLICT", + "T2C_CRITERION_GAP", + "T2C_PLAN_GAP", + "T2C_PRIORITY_DRIFT", + "T2C_SCOPE_EXPANSION", + "T2C_UNAUTHORIZED_DELETION", +} +REQUIRED_T2C_DIAGNOSTIC_CODES = { + "AMBIGUOUS_REQUIREMENT", + "HUMAN_AGENT_CONFLICT", + "HUMAN_COMMUNICATION_CONFLICT", + "PLANNED_NOT_IMPLEMENTED", +} + + +def _issue(code: str, path: str, message: str) -> dict[str, str]: + return {"code": code, "path": path, "message": message} + + +def _load_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError(f"cannot read JSON {path}: {error}") from error + if not isinstance(value, dict): + raise ValueError(f"JSON root must be an object: {path}") + return value + + +def _canonical(value: Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _digest(value: Any) -> str: + return hashlib.sha256(_canonical(value)).hexdigest() + + +def intent_projection(document: dict[str, Any]) -> dict[str, Any]: + """Return the authority-bearing projection used by advisory bindings.""" + projection = deepcopy(document) + projection.pop("advisoryAnalysis", None) + if projection.get("status") == "ANALYZED": + projection["status"] = "READY" + return projection + + +def intent_digest(document: dict[str, Any]) -> str: + return _digest(intent_projection(document)) + + +def _expect_object( + value: Any, + path: str, + errors: list[dict[str, str]], +) -> dict[str, Any]: + if not isinstance(value, dict): + errors.append(_issue(MALFORMED_CODE, path, "must be an object")) + return {} + return value + + + +def _cross_check_record( + value: dict[str, Any], + string_fields: tuple[str, ...], + object_fields: tuple[str, ...] = (), +) -> dict[str, Any]: + """Copy already diagnosed collections for safe cross-field checks. + + Preserve exact string bytes and the original input/digest. Invalid entries + remain rejected by the preceding field validators, never repaired or + promoted into a valid plan by this private view. + """ + result = dict(value) + for field in string_fields: + raw = value.get(field) + result[field] = ([item for item in raw if isinstance(item, str)] + if isinstance(raw, list) else []) + for field in object_fields: + raw = value.get(field) + result[field] = ([item for item in raw if isinstance(item, dict)] + if isinstance(raw, list) else []) + return result + + +def _exact_fields( + value: dict[str, Any], + path: str, + required: set[str], + optional: set[str], + errors: list[dict[str, str]], +) -> None: + missing = sorted(required - set(value)) + unknown = sorted(set(value) - required - optional) + if missing: + errors.append( + _issue(MALFORMED_CODE, path, f"missing fields: {', '.join(missing)}") + ) + if unknown: + errors.append( + _issue(MALFORMED_CODE, path, f"unknown fields: {', '.join(unknown)}") + ) + + +def _nonempty_text( + value: Any, + path: str, + errors: list[dict[str, str]], +) -> str: + if not isinstance(value, str) or not value.strip(): + errors.append(_issue(MALFORMED_CODE, path, "must be a non-empty string")) + return "" + return value.strip() + + +def _enum( + value: Any, + allowed: set[str], + path: str, + errors: list[dict[str, str]], +) -> str: + result = _nonempty_text(value, path, errors) + if result and result not in allowed: + errors.append( + _issue( + MALFORMED_CODE, + path, + f"must be one of: {', '.join(sorted(allowed))}", + ) + ) + return result + + +def _string_list( + value: Any, + path: str, + errors: list[dict[str, str]], + *, + minimum: int = 0, +) -> list[str]: + if not isinstance(value, list): + errors.append(_issue(MALFORMED_CODE, path, "must be an array")) + return [] + result: list[str] = [] + for index, item in enumerate(value): + text = _nonempty_text(item, f"{path}[{index}]", errors) + if text: + result.append(text) + if len(result) < minimum: + errors.append( + _issue(MALFORMED_CODE, path, f"must contain at least {minimum} item(s)") + ) + if len(result) != len(set(result)): + errors.append(_issue(MALFORMED_CODE, path, "must contain unique items")) + return result + + +def _pattern( + value: Any, + pattern: re.Pattern[str], + path: str, + errors: list[dict[str, str]], +) -> str: + result = _nonempty_text(value, path, errors) + if result and pattern.fullmatch(result) is None: + errors.append(_issue(MALFORMED_CODE, path, "has an invalid identifier")) + return result + + +def _safe_path(value: str) -> bool: + if value == "unresolved:agent": + return True + if not value or "\\" in value or value.startswith("/"): + return False + if re.match(r"^[A-Za-z]:", value): + return False + path = PurePosixPath(value) + return ".." not in path.parts and not value.startswith("unresolved:") + + +def _path_list( + value: Any, + path: str, + errors: list[dict[str, str]], + *, + minimum: int = 0, +) -> list[str]: + result = _string_list(value, path, errors, minimum=minimum) + for index, item in enumerate(result): + if not _safe_path(item): + errors.append( + _issue(MALFORMED_CODE, f"{path}[{index}]", "must be a safe relative path") + ) + return result + + +def _duplicate_ids( + items: list[Any], + path: str, + errors: list[dict[str, str]], +) -> None: + seen: set[str] = set() + for index, item in enumerate(items): + if not isinstance(item, dict) or not isinstance(item.get("id"), str): + continue + item_id = item["id"] + if item_id in seen: + errors.append( + _issue(MALFORMED_CODE, f"{path}[{index}].id", f"duplicate id: {item_id}") + ) + seen.add(item_id) + + +def _cycles(graph: dict[str, list[str]]) -> list[list[str]]: + cycles: list[list[str]] = [] + visited: set[str] = set() + active: list[str] = [] + active_set: set[str] = set() + + def visit(node: str) -> None: + if node in active_set: + start = active.index(node) + cycles.append(active[start:] + [node]) + return + if node in visited: + return + visited.add(node) + active.append(node) + active_set.add(node) + for dependency in graph.get(node, []): + if dependency in graph: + visit(dependency) + active.pop() + active_set.remove(node) + + for node in graph: + visit(node) + return cycles + + +def _matches(path: str, patterns: list[str]) -> bool: + path_parts = path.replace("\\", "/").strip("/").split("/") + + def match_pattern(pattern: str) -> bool: + pattern_parts = pattern.replace("\\", "/").strip("/").split("/") + memo: dict[tuple[int, int], bool] = {} + + def visit(path_index: int, pattern_index: int) -> bool: + key = (path_index, pattern_index) + if key in memo: + return memo[key] + if pattern_index == len(pattern_parts): + result = path_index == len(path_parts) + elif pattern_parts[pattern_index] == "**": + result = visit(path_index, pattern_index + 1) or ( + path_index < len(path_parts) and visit(path_index + 1, pattern_index) + ) + else: + result = ( + path_index < len(path_parts) + and fnmatchcase(path_parts[path_index], pattern_parts[pattern_index]) + and visit(path_index + 1, pattern_index + 1) + ) + memo[key] = result + return result + + return visit(0, 0) + + return any(match_pattern(pattern) for pattern in patterns) + + +def _ancestors(action_id: str, graph: dict[str, list[str]]) -> set[str]: + result: set[str] = set() + pending = list(graph.get(action_id, [])) + while pending: + candidate = pending.pop() + if candidate in result: + continue + result.add(candidate) + pending.extend(graph.get(candidate, [])) + return result + + +def _validate_source( + document: dict[str, Any], + errors: list[dict[str, str]], + warnings: list[dict[str, str]], +) -> None: + source = _expect_object(document.get("source"), "source", errors) + _exact_fields(source, "source", {"producer", "observedAt", "reportDigest"}, set(), errors) + producer = _expect_object(source.get("producer"), "source.producer", errors) + _exact_fields(producer, "source.producer", {"name", "version"}, set(), errors) + _nonempty_text(producer.get("name"), "source.producer.name", errors) + _nonempty_text(producer.get("version"), "source.producer.version", errors) + observed = _nonempty_text(source.get("observedAt"), "source.observedAt", errors) + if observed: + try: + if SOURCE_DATE_TIME.fullmatch(source["observedAt"]) is None: + raise ValueError("date-time requires full time and an explicit zone") + datetime.fromisoformat(observed.upper().replace("Z", "+00:00")) + except ValueError: + errors.append( + _issue(MALFORMED_CODE, "source.observedAt", "must be a full ISO-8601 date-time with an explicit timezone") + ) + report_digest = _nonempty_text( + source.get("reportDigest"), "source.reportDigest", errors + ) + if report_digest and report_digest != "unresolved:agent" and DIGEST.fullmatch(report_digest) is None: + errors.append( + _issue(MALFORMED_CODE, "source.reportDigest", "must be a SHA-256 digest") + ) + if report_digest == "unresolved:agent": + warnings.append( + _issue(MALFORMED_CODE, "source.reportDigest", "report digest is unresolved") + ) + + +def _validate_objective_scope( + document: dict[str, Any], + errors: list[dict[str, str]], +) -> tuple[list[str], list[str], list[str]]: + objective = _expect_object(document.get("objective"), "objective", errors) + _exact_fields(objective, "objective", {"outcome", "nonGoals", "constraints"}, set(), errors) + _nonempty_text(objective.get("outcome"), "objective.outcome", errors) + _string_list(objective.get("nonGoals"), "objective.nonGoals", errors, minimum=1) + _string_list(objective.get("constraints"), "objective.constraints", errors, minimum=1) + + scope = _expect_object(document.get("scope"), "scope", errors) + _exact_fields(scope, "scope", {"allowedPaths", "forbiddenPaths", "preservePaths"}, set(), errors) + allowed = _path_list(scope.get("allowedPaths"), "scope.allowedPaths", errors, minimum=1) + forbidden = _path_list(scope.get("forbiddenPaths"), "scope.forbiddenPaths", errors) + preserve = _path_list(scope.get("preservePaths"), "scope.preservePaths", errors) + return allowed, forbidden, preserve + + +def _validate_finding_evidence(finding, path, errors): + evidence = finding.get("evidence") + if not isinstance(evidence, list) or not evidence: + errors.append(_issue(MALFORMED_CODE, f"{path}.evidence", "must be a non-empty array")) + else: + for evidence_index, evidence_candidate in enumerate(evidence): + evidence_path = f"{path}.evidence[{evidence_index}]" + item = _expect_object(evidence_candidate, evidence_path, errors) + _exact_fields(item, evidence_path, {"ref", "observation"}, set(), errors) + _nonempty_text(item.get("ref"), f"{evidence_path}.ref", errors) + _nonempty_text(item.get("observation"), f"{evidence_path}.observation", errors) + + + +def _validate_finding_applicability(finding, path, errors): + applicability = _expect_object(finding.get("applicability"), f"{path}.applicability", errors) + _exact_fields( + applicability, + f"{path}.applicability", + {"requiredSignals", "excludedSignals", "unknownOutcome"}, + set(), + errors, + ) + required_signals = _string_list( + applicability.get("requiredSignals"), + f"{path}.applicability.requiredSignals", + errors, + minimum=1, + ) + excluded_signals = _string_list( + applicability.get("excludedSignals"), + f"{path}.applicability.excludedSignals", + errors, + ) + _enum( + applicability.get("unknownOutcome"), + {"BLOCK", "REPORT"}, + f"{path}.applicability.unknownOutcome", + errors, + ) + return required_signals, excluded_signals + + +def _validate_finding_transition(category, current, required, required_signals, excluded_signals, path, errors): + if category == "FALSE_POSITIVE": + if current != "FALSE_POSITIVE" or required not in {"REFINE", "SUPPRESS"}: + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.diagnostic", + "FALSE_POSITIVE requires current=FALSE_POSITIVE and required=REFINE|SUPPRESS", + ) + ) + if not required_signals or not excluded_signals: + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.applicability", + "FALSE_POSITIVE requires both positive and excluded signals", + ) + ) + if category in {"SILENT_OMISSION", "MISSING_INVENTORY"} and ( + current != "MISSING" or required != "EMIT" + ): + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.diagnostic", + f"{category} requires current=MISSING and required=EMIT", + ) + ) + + +def _validate_findings( + document: dict[str, Any], + errors: list[dict[str, str]], +) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: + raw = document.get("findings") + if not isinstance(raw, list) or not raw: + errors.append(_issue(MALFORMED_CODE, "findings", "must be a non-empty array")) + return [], {} + _duplicate_ids(raw, "findings", errors) + findings: list[dict[str, Any]] = [] + for index, candidate in enumerate(raw): + path = f"findings[{index}]" + finding = _expect_object(candidate, path, errors) + _exact_fields( + finding, + path, + { + "id", + "category", + "status", + "priority", + "summary", + "diagnostic", + "evidence", + "applicability", + "desiredOutcome", + "affectedPaths", + "dependsOn", + "acceptanceCriteria", + }, + set(), + errors, + ) + finding_id = _pattern(finding.get("id"), FINDING_ID, f"{path}.id", errors) + category = _enum(finding.get("category"), FINDING_CATEGORIES, f"{path}.category", errors) + _enum(finding.get("status"), FINDING_STATUSES, f"{path}.status", errors) + _enum(finding.get("priority"), PRIORITIES, f"{path}.priority", errors) + _nonempty_text(finding.get("summary"), f"{path}.summary", errors) + diagnostic = _expect_object(finding.get("diagnostic"), f"{path}.diagnostic", errors) + _exact_fields(diagnostic, f"{path}.diagnostic", {"code", "current", "required"}, set(), errors) + code = _pattern(diagnostic.get("code"), DIAGNOSTIC_CODE, f"{path}.diagnostic.code", errors) + current = _enum(diagnostic.get("current"), DIAGNOSTIC_STATES, f"{path}.diagnostic.current", errors) + required = _enum(diagnostic.get("required"), DIAGNOSTIC_OUTCOMES, f"{path}.diagnostic.required", errors) + + _validate_finding_evidence(finding, path, errors) + + required_signals, excluded_signals = _validate_finding_applicability(finding, path, errors) + _nonempty_text(finding.get("desiredOutcome"), f"{path}.desiredOutcome", errors) + _path_list(finding.get("affectedPaths"), f"{path}.affectedPaths", errors, minimum=1) + _string_list(finding.get("dependsOn"), f"{path}.dependsOn", errors) + _string_list( + finding.get("acceptanceCriteria"), + f"{path}.acceptanceCriteria", + errors, + minimum=1, + ) + + _validate_finding_transition(category, current, required, required_signals, excluded_signals, path, errors) + if finding_id and code: + findings.append(_cross_check_record( + finding, ("affectedPaths", "dependsOn", "acceptanceCriteria"), ("evidence",) + )) + finding_by_id = {item["id"]: item for item in findings} + graph: dict[str, list[str]] = {} + for index, finding in enumerate(findings): + dependencies = finding.get("dependsOn", []) + graph[finding["id"]] = dependencies if isinstance(dependencies, list) else [] + for dependency in graph[finding["id"]]: + if dependency not in finding_by_id: + errors.append( + _issue( + MALFORMED_CODE, + f"findings[{index}].dependsOn", + f"unknown finding dependency: {dependency}", + ) + ) + for cycle in _cycles(graph): + errors.append( + _issue(MALFORMED_CODE, "findings", f"dependency cycle: {' -> '.join(cycle)}") + ) + return findings, finding_by_id + + +def _validate_verifications( + document: dict[str, Any], + errors: list[dict[str, str]], +) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: + raw = document.get("verifications") + if not isinstance(raw, list) or not raw: + errors.append( + _issue(MALFORMED_CODE, "verifications", "must be a non-empty array") + ) + return [], {} + _duplicate_ids(raw, "verifications", errors) + result: list[dict[str, Any]] = [] + for index, candidate in enumerate(raw): + path = f"verifications[{index}]" + item = _expect_object(candidate, path, errors) + _exact_fields( + item, + path, + {"id", "type", "command", "expected", "deterministic", "covers"}, + set(), + errors, + ) + verification_id = _pattern(item.get("id"), VERIFICATION_ID, f"{path}.id", errors) + verification_type = _enum(item.get("type"), VERIFICATION_TYPES, f"{path}.type", errors) + command = item.get("command") + if verification_type == "COMMAND": + _nonempty_text(command, f"{path}.command", errors) + elif command is not None: + _nonempty_text(command, f"{path}.command", errors) + _nonempty_text(item.get("expected"), f"{path}.expected", errors) + if not isinstance(item.get("deterministic"), bool): + errors.append(_issue(MALFORMED_CODE, f"{path}.deterministic", "must be boolean")) + _string_list(item.get("covers"), f"{path}.covers", errors, minimum=1) + if verification_id: + result.append(_cross_check_record(item, ("covers",))) + return result, {item["id"]: item for item in result} + + +def _validate_action_verifications(verification_ids, verification_by_id, action_id, finding_ids, path, errors): + verification_coverage: set[str] = set() + for verification_id in verification_ids: + verification = verification_by_id.get(verification_id) + if verification is None: + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.verificationIds", + f"unknown verification: {verification_id}", + ) + ) + elif verification.get("deterministic") is not True: + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.verificationIds", + f"action requires deterministic verification: {verification_id}", + ) + ) + else: + verification_coverage.update(verification.get("covers", [])) + missing_coverage = sorted( + {action_id, *finding_ids} - verification_coverage + ) + if missing_coverage: + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.verificationIds", + "selected verifications do not cover: " + + ", ".join(missing_coverage), + ) + ) + + +def _validate_action_paths(paths, allowed, forbidden, path, errors): + for action_path in paths: + if action_path == "unresolved:agent": + continue + if not _matches(action_path, allowed): + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.paths", + f"path is outside scope.allowedPaths: {action_path}", + ) + ) + if _matches(action_path, forbidden): + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.paths", + f"path matches scope.forbiddenPaths: {action_path}", + ) + ) + + +def _validate_action_risk(level, authorization, automation, finding_ids, finding_by_id, operation, preserves_user_data, path, errors): + if level == "DESTRUCTIVE" and ( + authorization != "EXPLICIT_HUMAN" or automation != "PROHIBITED" + ): + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.risk", + "DESTRUCTIVE action requires EXPLICIT_HUMAN and PROHIBITED automation", + ) + ) + state_risk = any( + finding_by_id.get(finding_id, {}).get("category") == "STATE_RISK" + for finding_id in finding_ids + ) + if state_risk and ( + operation != "PRESERVE" + or automation != "PROHIBITED" + or preserves_user_data is not True + ): + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.risk", + "STATE_RISK requires a PRESERVE action with prohibited automation and preserved user data", + ) + ) + + +def _validate_action_dependencies(actions, finding_by_id, errors): + action_ids = {item["id"] for item in actions} + graph: dict[str, list[str]] = {} + for index, action in enumerate(actions): + dependencies = action.get("dependsOn", []) + graph[action["id"]] = dependencies if isinstance(dependencies, list) else [] + for dependency in graph[action["id"]]: + if dependency not in action_ids: + errors.append( + _issue( + MALFORMED_CODE, + f"actions[{index}].dependsOn", + f"unknown action dependency: {dependency}", + ) + ) + for cycle in _cycles(graph): + errors.append( + _issue(MALFORMED_CODE, "actions", f"dependency cycle: {' -> '.join(cycle)}") + ) + + blocking_actions = { + action["id"] + for action in actions + if action.get("operation") != "RELEASE" + and any( + finding_by_id.get(finding_id, {}).get("priority") in {"P0", "P1"} + and finding_by_id.get(finding_id, {}).get("status") != "DEFERRED" + for finding_id in action.get("findingIds", []) + ) + } + for index, action in enumerate(actions): + if action.get("operation") != "RELEASE": + continue + missing = sorted(blocking_actions - _ancestors(action["id"], graph)) + if missing: + errors.append( + _issue( + MALFORMED_CODE, + f"actions[{index}].dependsOn", + "RELEASE must depend transitively on P0/P1 repair actions: " + + ", ".join(missing), + ) + ) + return graph + + +def _validate_actions( + document: dict[str, Any], + finding_by_id: dict[str, dict[str, Any]], + verification_by_id: dict[str, dict[str, Any]], + allowed: list[str], + forbidden: list[str], + preserve: list[str], + errors: list[dict[str, str]], +) -> tuple[list[dict[str, Any]], dict[str, list[str]]]: + raw = document.get("actions") + if not isinstance(raw, list) or not raw: + errors.append(_issue(MALFORMED_CODE, "actions", "must be a non-empty array")) + return [], {} + _duplicate_ids(raw, "actions", errors) + actions: list[dict[str, Any]] = [] + for index, candidate in enumerate(raw): + path = f"actions[{index}]" + item = _expect_object(candidate, path, errors) + _exact_fields( + item, + path, + { + "id", + "findingIds", + "operation", + "description", + "paths", + "dependsOn", + "verificationIds", + "risk", + }, + set(), + errors, + ) + action_id = _pattern(item.get("id"), ACTION_ID, f"{path}.id", errors) + finding_ids = _string_list(item.get("findingIds"), f"{path}.findingIds", errors, minimum=1) + operation = _enum(item.get("operation"), OPERATIONS, f"{path}.operation", errors) + _nonempty_text(item.get("description"), f"{path}.description", errors) + paths = _path_list(item.get("paths"), f"{path}.paths", errors, minimum=1) + dependencies = _string_list(item.get("dependsOn"), f"{path}.dependsOn", errors) + verification_ids = _string_list( + item.get("verificationIds"), f"{path}.verificationIds", errors, minimum=1 + ) + risk = _expect_object(item.get("risk"), f"{path}.risk", errors) + _exact_fields( + risk, + f"{path}.risk", + {"level", "authorization", "automation", "preservesUserData"}, + set(), + errors, + ) + level = _enum(risk.get("level"), RISK_LEVELS, f"{path}.risk.level", errors) + authorization = _enum( + risk.get("authorization"), + AUTHORIZATIONS, + f"{path}.risk.authorization", + errors, + ) + automation = _enum( + risk.get("automation"), AUTOMATION_VALUES, f"{path}.risk.automation", errors + ) + preserves_user_data = risk.get("preservesUserData") + if not isinstance(preserves_user_data, bool): + errors.append( + _issue(MALFORMED_CODE, f"{path}.risk.preservesUserData", "must be boolean") + ) + for finding_id in finding_ids: + if finding_id not in finding_by_id: + errors.append( + _issue(MALFORMED_CODE, f"{path}.findingIds", f"unknown finding: {finding_id}") + ) + _validate_action_verifications(verification_ids, verification_by_id, action_id, finding_ids, path, errors) + _validate_action_paths(paths, allowed, forbidden, path, errors) + _validate_action_risk(level, authorization, automation, finding_ids, finding_by_id, operation, preserves_user_data, path, errors) + if operation == "PRESERVE" and preserve and not any( + _matches(action_path, preserve) + for action_path in paths + if action_path != "unresolved:agent" + ): + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.paths", + "PRESERVE action must reference scope.preservePaths", + ) + ) + if action_id: + actions.append(_cross_check_record( + item, ("paths", "dependsOn", "findingIds", "verificationIds") + )) + + graph = _validate_action_dependencies(actions, finding_by_id, errors) + return actions, graph + + +def _validate_planning_guidance(document, action_graph, errors): + guidance = _expect_object(document.get("llmGuidance"), "llmGuidance", errors) + _exact_fields( + guidance, + "llmGuidance", + {"role", "mustPreserve", "forbiddenAssumptions", "planningOrder", "openQuestions"}, + set(), + errors, + ) + _nonempty_text(guidance.get("role"), "llmGuidance.role", errors) + _string_list(guidance.get("mustPreserve"), "llmGuidance.mustPreserve", errors, minimum=1) + _string_list( + guidance.get("forbiddenAssumptions"), + "llmGuidance.forbiddenAssumptions", + errors, + minimum=1, + ) + planning_order = _string_list( + guidance.get("planningOrder"), "llmGuidance.planningOrder", errors, minimum=1 + ) + _string_list(guidance.get("openQuestions"), "llmGuidance.openQuestions", errors) + if set(planning_order) != set(action_graph) or len(planning_order) != len(set(planning_order)): + errors.append( + _issue( + MALFORMED_CODE, + "llmGuidance.planningOrder", + "must contain every action id exactly once", + ) + ) + position = {action_id: index for index, action_id in enumerate(planning_order)} + for action_id, dependencies in action_graph.items(): + for dependency in dependencies: + if position.get(dependency, -1) >= position.get(action_id, -1): + errors.append( + _issue( + MALFORMED_CODE, + "llmGuidance.planningOrder", + f"dependency order violated: {dependency} before {action_id}", + ) + ) + + + +def _validate_todo2code_contract(document, errors): + todo2code = _expect_object(document.get("todo2code"), "todo2code", errors) + _exact_fields( + todo2code, + "todo2code", + {"enabled", "taskPath", "todoPath", "planSchema", "requiredDiagnosticCodes"}, + set(), + errors, + ) + if not isinstance(todo2code.get("enabled"), bool): + errors.append(_issue(MALFORMED_CODE, "todo2code.enabled", "must be boolean")) + _path_list([todo2code.get("taskPath")], "todo2code.taskPath", errors, minimum=1) + _path_list([todo2code.get("todoPath")], "todo2code.todoPath", errors, minimum=1) + if todo2code.get("planSchema") != T2C_PLAN_SCHEMA: + errors.append( + _issue(MALFORMED_CODE, "todo2code.planSchema", f"must be {T2C_PLAN_SCHEMA}") + ) + diagnostic_codes = _string_list( + todo2code.get("requiredDiagnosticCodes"), + "todo2code.requiredDiagnosticCodes", + errors, + minimum=1, + ) + missing_diagnostic_codes = sorted( + REQUIRED_T2C_DIAGNOSTIC_CODES - set(diagnostic_codes) + ) + unknown_diagnostic_codes = sorted( + set(diagnostic_codes) - REQUIRED_T2C_DIAGNOSTIC_CODES + ) + if missing_diagnostic_codes: + errors.append( + _issue( + MALFORMED_CODE, + "todo2code.requiredDiagnosticCodes", + "missing required consistency diagnostics: " + + ", ".join(missing_diagnostic_codes), + ) + ) + if unknown_diagnostic_codes: + errors.append( + _issue( + MALFORMED_CODE, + "todo2code.requiredDiagnosticCodes", + "unsupported consistency diagnostics: " + + ", ".join(unknown_diagnostic_codes), + ) + ) + + +def _validate_finding_criteria(criteria, finding_by_id, errors): + criterion_ids = {item["id"] for item in criteria} + for finding_id, finding in finding_by_id.items(): + for criterion_id in finding.get("acceptanceCriteria", []): + if criterion_id not in criterion_ids: + errors.append( + _issue( + MALFORMED_CODE, + f"finding:{finding_id}.acceptanceCriteria", + f"unknown acceptance criterion: {criterion_id}", + ) + ) + elif finding_id not in next( + item["findingIds"] for item in criteria if item["id"] == criterion_id + ): + errors.append( + _issue( + MALFORMED_CODE, + f"finding:{finding_id}.acceptanceCriteria", + f"criterion does not bind this finding: {criterion_id}", + ) + ) + + + +def _validate_criteria_guidance_t2c( + document: dict[str, Any], + finding_by_id: dict[str, dict[str, Any]], + action_graph: dict[str, list[str]], + verification_by_id: dict[str, dict[str, Any]], + errors: list[dict[str, str]], +) -> None: + raw_criteria = document.get("acceptanceCriteria") + if not isinstance(raw_criteria, list) or not raw_criteria: + errors.append( + _issue(MALFORMED_CODE, "acceptanceCriteria", "must be a non-empty array") + ) + criteria: list[dict[str, Any]] = [] + else: + _duplicate_ids(raw_criteria, "acceptanceCriteria", errors) + criteria = [] + for index, candidate in enumerate(raw_criteria): + path = f"acceptanceCriteria[{index}]" + item = _expect_object(candidate, path, errors) + _exact_fields( + item, + path, + {"id", "statement", "findingIds", "verificationIds"}, + set(), + errors, + ) + criterion_id = _pattern(item.get("id"), CRITERION_ID, f"{path}.id", errors) + _nonempty_text(item.get("statement"), f"{path}.statement", errors) + finding_ids = _string_list(item.get("findingIds"), f"{path}.findingIds", errors, minimum=1) + verification_ids = _string_list( + item.get("verificationIds"), f"{path}.verificationIds", errors, minimum=1 + ) + for finding_id in finding_ids: + if finding_id not in finding_by_id: + errors.append( + _issue(MALFORMED_CODE, f"{path}.findingIds", f"unknown finding: {finding_id}") + ) + for verification_id in verification_ids: + if verification_id not in verification_by_id: + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.verificationIds", + f"unknown verification: {verification_id}", + ) + ) + verification_coverage = { + covered + for verification_id in verification_ids + for covered in verification_by_id.get(verification_id, {}).get( + "covers", [] + ) + } + uncovered_findings = sorted( + set(finding_ids) - verification_coverage + ) + if uncovered_findings: + errors.append( + _issue( + MALFORMED_CODE, + f"{path}.verificationIds", + "criterion verifications do not cover findings: " + + ", ".join(uncovered_findings), + ) + ) + if criterion_id: + criteria.append(_cross_check_record(item, ("findingIds", "verificationIds"))) + + _validate_finding_criteria(criteria, finding_by_id, errors) + _validate_planning_guidance(document, action_graph, errors) + _validate_todo2code_contract(document, errors) + + +def _validate_analysis( + document: dict[str, Any], + errors: list[dict[str, str]], +) -> None: + status = document.get("status") + analysis = document.get("advisoryAnalysis") + if status == "ANALYZED" and not isinstance(analysis, dict): + errors.append( + _issue(MALFORMED_CODE, "advisoryAnalysis", "ANALYZED status requires advisoryAnalysis") + ) + return + if analysis is None: + return + analysis = _expect_object(analysis, "advisoryAnalysis", errors) + _exact_fields( + analysis, + "advisoryAnalysis", + { + "authority", + "producer", + "analyzedAt", + "intentDigest", + "graphDigest", + "diagnosticsDigest", + "plansDigest", + "projectionRecordIds", + "planIds", + "findings", + "llmHints", + }, + set(), + errors, + ) + if analysis.get("authority") != "ADVISORY": + errors.append( + _issue(MALFORMED_CODE, "advisoryAnalysis.authority", "must be ADVISORY") + ) + producer = _expect_object(analysis.get("producer"), "advisoryAnalysis.producer", errors) + _exact_fields(producer, "advisoryAnalysis.producer", {"name", "version", "mode"}, set(), errors) + if producer.get("name") != "todo2code" or producer.get("mode") != "deterministic": + errors.append( + _issue( + MALFORMED_CODE, + "advisoryAnalysis.producer", + "must identify deterministic todo2code", + ) + ) + _nonempty_text(producer.get("version"), "advisoryAnalysis.producer.version", errors) + _nonempty_text(analysis.get("analyzedAt"), "advisoryAnalysis.analyzedAt", errors) + for field in ("intentDigest", "graphDigest", "diagnosticsDigest", "plansDigest"): + value = _nonempty_text(analysis.get(field), f"advisoryAnalysis.{field}", errors) + if value and DIGEST.fullmatch(value) is None: + errors.append( + _issue(MALFORMED_CODE, f"advisoryAnalysis.{field}", "must be SHA-256") + ) + if analysis.get("intentDigest") != intent_digest(document): + errors.append( + _issue( + STALE_CODE, + "advisoryAnalysis.intentDigest", + "analysis is stale for the authority-bearing intent projection", + ) + ) + _string_list( + analysis.get("projectionRecordIds"), + "advisoryAnalysis.projectionRecordIds", + errors, + minimum=1, + ) + _string_list(analysis.get("planIds"), "advisoryAnalysis.planIds", errors) + _string_list(analysis.get("llmHints"), "advisoryAnalysis.llmHints", errors) + findings = analysis.get("findings") + if not isinstance(findings, list): + errors.append(_issue(MALFORMED_CODE, "advisoryAnalysis.findings", "must be an array")) + else: + for index, candidate in enumerate(findings): + path = f"advisoryAnalysis.findings[{index}]" + item = _expect_object(candidate, path, errors) + _exact_fields(item, path, {"code", "severity", "message", "references", "llmHint"}, set(), errors) + _enum(item.get("code"), ANALYSIS_CODES, f"{path}.code", errors) + _enum(item.get("severity"), {"BLOCKING", "REVIEW", "INFO"}, f"{path}.severity", errors) + _nonempty_text(item.get("message"), f"{path}.message", errors) + _string_list(item.get("references"), f"{path}.references", errors, minimum=1) + _nonempty_text(item.get("llmHint"), f"{path}.llmHint", errors) + + +def _validate_finding_paths(findings, allowed, forbidden, errors): + for index, finding in enumerate(findings): + finding_id = finding.get("id") if isinstance(finding, dict) else None + id_str = str(finding_id) if finding_id is not None else str(index) + for affected_path in finding.get("affectedPaths", []) if isinstance(finding, dict) else []: + if affected_path == "unresolved:agent": + continue + if not _matches(affected_path, allowed): + errors.append( + _issue( + MALFORMED_CODE, + f"finding:{id_str}.affectedPaths", + f"path is outside scope.allowedPaths: {affected_path}", + ) + ) + if _matches(affected_path, forbidden): + errors.append( + _issue( + MALFORMED_CODE, + f"finding:{id_str}.affectedPaths", + f"path matches scope.forbiddenPaths: {affected_path}", + ) + ) + + +def _validate_unresolved_paths(document, status, owner_route, allowed, findings, actions, errors, warnings): + unresolved_paths: list[str] = [] + source = document.get("source") + if ( + status in {"READY", "ANALYZED"} + and isinstance(source, dict) + and source.get("reportDigest") == "unresolved:agent" + ): + unresolved_paths.append("source.reportDigest") + if owner_route in {"unresolved:human", "unresolved:agent"}: + unresolved_paths.append("ownerRoute") + for path in allowed: + if path == "unresolved:agent": + unresolved_paths.append("scope.allowedPaths") + for index, finding in enumerate(findings): + if not isinstance(finding, dict): + continue + finding_id = finding.get("id") + id_str = str(finding_id) if finding_id is not None else str(index) + if "unresolved:agent" in finding.get("affectedPaths", []): + unresolved_paths.append(f"finding:{id_str}.affectedPaths") + if any(isinstance(item, dict) and item.get("ref") == "unresolved:agent" for item in finding.get("evidence", [])): + unresolved_paths.append(f"finding:{id_str}.evidence") + for action in actions: + if "unresolved:agent" in action.get("paths", []): + unresolved_paths.append(f"action:{action['id']}.paths") + if unresolved_paths: + target = errors if status in {"READY", "ANALYZED"} else warnings + for path in unresolved_paths: + target.append( + _issue( + MALFORMED_CODE, + path, + "unresolved path is allowed only while status=DRAFT", + ) + ) + + + +def validate_document(document: dict[str, Any]) -> dict[str, Any]: + errors: list[dict[str, str]] = [] + warnings: list[dict[str, str]] = [] + _exact_fields( + document, + "$", + { + "schema", + "intentId", + "ticket", + "repository", + "ownerRoute", + "status", + "source", + "objective", + "scope", + "findings", + "actions", + "verifications", + "acceptanceCriteria", + "llmGuidance", + "todo2code", + }, + {"advisoryAnalysis"}, + errors, + ) + if document.get("schema") != INTENT_SCHEMA: + errors.append(_issue(MALFORMED_CODE, "schema", f"must be {INTENT_SCHEMA}")) + _pattern(document.get("intentId"), INTENT_ID, "intentId", errors) + _pattern(document.get("ticket"), TICKET_ID, "ticket", errors) + _pattern(document.get("repository"), REPOSITORY, "repository", errors) + owner_route = _nonempty_text(document.get("ownerRoute"), "ownerRoute", errors) + status = _enum(document.get("status"), {"DRAFT", "READY", "ANALYZED"}, "status", errors) + _validate_source(document, errors, warnings) + allowed, forbidden, preserve = _validate_objective_scope(document, errors) + findings, finding_by_id = _validate_findings(document, errors) + _validate_finding_paths(findings, allowed, forbidden, errors) + verifications, verification_by_id = _validate_verifications(document, errors) + actions, action_graph = _validate_actions( + document, + finding_by_id, + verification_by_id, + allowed, + forbidden, + preserve, + errors, + ) + action_finding_ids = { + finding_id + for action in actions + for finding_id in action.get("findingIds", []) + } + for finding in findings: + if not isinstance(finding, dict): + continue + fid = finding.get("id") + if not fid: + continue + if finding.get("status") != "DEFERRED" and fid not in action_finding_ids: + errors.append( + _issue( + MALFORMED_CODE, + f"finding:{fid}", + "active finding must be resolved by at least one action", + ) + ) + _validate_criteria_guidance_t2c( + document, finding_by_id, action_graph, verification_by_id, errors + ) + _validate_analysis(document, errors) + + _validate_unresolved_paths(document, status, owner_route, allowed, findings, actions, errors, warnings) + finding_ids = {item["id"] for item in findings if isinstance(item, dict) and "id" in item} + action_ids = {item["id"] for item in actions} + for verification in verifications: + for covered in verification.get("covers", []): + if covered not in finding_ids | action_ids: + errors.append( + _issue( + MALFORMED_CODE, + f"verification:{verification['id']}.covers", + f"unknown covered id: {covered}", + ) + ) + + return { + "schema": VALIDATION_SCHEMA, + "intentId": document.get("intentId"), + "intentDigest": intent_digest(document), + "findings": len(findings), + "actions": len(actions), + "errors": errors, + "warnings": warnings, + "ok": not errors, + } + + +def _require_valid(document: dict[str, Any], *, ready: bool = False) -> dict[str, Any]: + report = validate_document(document) + if ready and document.get("status") == "DRAFT": + report["errors"].append( + _issue(MALFORMED_CODE, "status", "todo2code projection requires READY or ANALYZED") + ) + report["ok"] = False + if not report["ok"]: + raise ValueError(json.dumps(report, ensure_ascii=False, indent=2)) + return report + + +def _criterion_map(document: dict[str, Any]) -> dict[str, dict[str, Any]]: + return { + criterion["id"]: criterion + for criterion in document.get("acceptanceCriteria", []) + if isinstance(criterion, dict) and isinstance(criterion.get("id"), str) + } + + +def _atomic_fragment(value: Any) -> str: + """Keep prose readable without creating a second todo2code statement.""" + text = re.sub(r"\s+", " ", str(value)).strip() + text = re.sub(r"[.!?;]+(?=\s|$)", ",", text) + return text.strip(" ,") + + +def _atomic_json_string(value: Any) -> str: + """Encode an exact scalar while preventing sentence-boundary splitting.""" + encoded = json.dumps(str(value), ensure_ascii=True) + return re.sub(r"(?<=[.!?;]) (?=[A-Z0-9])", r"\\u0020", encoded) + + +def _action_prefix(operation: str) -> str: + return { + "TEST": "test(remediation)", + "DOCUMENT": "docs(remediation)", + "RELEASE": "build(remediation)", + "TRIAGE": "chore(remediation)", + "PRESERVE": "chore(remediation)", + }.get(operation, "fix(remediation)") + + +def _todo2code_action_line( + document: dict[str, Any], + action: dict[str, Any], + findings: dict[str, dict[str, Any]], + criteria: dict[str, dict[str, Any]], + verifications: dict[str, dict[str, Any]], + digest: str, +) -> str: + finding_items = [findings[finding_id] for finding_id in action["findingIds"]] + finding_labels = ", ".join( + f"{item['id']}/{item['diagnostic']['code']}/{item['priority']}" + for item in finding_items + ) + criterion_ids = list( + dict.fromkeys( + criterion_id + for finding in finding_items + for criterion_id in finding["acceptanceCriteria"] + ) + ) + criterion_text = ", ".join( + f"{criterion_id} {_atomic_fragment(criteria[criterion_id]['statement'])}" + for criterion_id in criterion_ids + if criterion_id in criteria + ) + verification_text = ", ".join( + " ".join( + [ + verification_id, + verification["type"].lower(), + ( + f"command-json={_atomic_json_string(verification['command'])}" + if verification.get("command") + else "command-json=null" + ), + f"expected={_atomic_fragment(verification['expected'])}", + f"deterministic={str(verification['deterministic']).lower()}", + ] + ) + for verification_id in action["verificationIds"] + for verification in [verifications[verification_id]] + ) + paths = ", ".join(f"`{path}`" for path in action["paths"]) + dependencies = ", ".join(action["dependsOn"]) or "none" + risk = action["risk"] + return ( + f"{_action_prefix(action['operation'])}: action {action['id']} must " + f"{_atomic_fragment(action['description'])} | intent {document['intentId']} " + f"ticket {document['ticket']} digest {digest} | findings {finding_labels} | " + f"paths {paths} | dependencies {dependencies} | acceptance {criterion_text} | " + f"verification {verification_text} when executed and failure must block the action | " + f"risk {risk['level'].lower()} authorization {risk['authorization'].lower()} " + f"automation {risk['automation'].lower()} preserves-user-data " + f"{str(risk['preservesUserData']).lower()} | evidence result must pass." + ) + + +def _projection_headings(document: dict[str, Any], digest: str) -> list[str]: + lines = [ + f"## ticket: {document['ticket']}", + f"## repository: {document['repository']}", + f"## intent digest: {digest}", + "## authority: accepted remediation intent; todo2code and LLM output are advisory", + f"## outcome: {_atomic_fragment(document['objective']['outcome'])}", + ] + lines.extend( + f"### non-goal {index}: {_atomic_fragment(item)}" + for index, item in enumerate(document["objective"]["nonGoals"], start=1) + ) + lines.extend( + f"### constraint {index}: {_atomic_fragment(item)}" + for index, item in enumerate(document["objective"]["constraints"], start=1) + ) + lines.extend( + f"### must preserve {index}: {_atomic_fragment(item)}" + for index, item in enumerate(document["llmGuidance"]["mustPreserve"], start=1) + ) + lines.extend( + f"### forbidden assumption {index}: {_atomic_fragment(item)}" + for index, item in enumerate( + document["llmGuidance"]["forbiddenAssumptions"], start=1 + ) + ) + return lines + + +def _render_findings(document, criteria, lines): + for finding in document["findings"]: + diagnostic = finding["diagnostic"] + lines.extend( + [ + f"### {finding['id']} — {diagnostic['code']} ({finding['priority']})", + "", + f"- Category/state: `{finding['category']}` / `{finding['status']}`", + f"- Diagnostic transition: `{diagnostic['current']} -> {diagnostic['required']}`", + f"- Observation: {finding['summary']}", + f"- Desired outcome: {finding['desiredOutcome']}", + "- Required signals:", + ] + ) + lines.extend(f" - {item}" for item in finding["applicability"]["requiredSignals"]) + lines.append("- Excluded signals:") + excluded = finding["applicability"]["excludedSignals"] + lines.extend(f" - {item}" for item in excluded or ["(none declared)"]) + lines.append("- Evidence:") + lines.extend( + f" - `{item['ref']}` — {item['observation']}" for item in finding["evidence"] + ) + lines.append("- Acceptance:") + lines.extend( + f" - [{criterion_id}] {criteria[criterion_id]['statement']}" + for criterion_id in finding["acceptanceCriteria"] + if criterion_id in criteria + ) + lines.append("") + + +def render_llm(document: dict[str, Any]) -> str: + report = _require_valid(document, ready=True) + criteria = _criterion_map(document) + lines = [ + f"# Remediation planning brief: {document['intentId']}", + "", + f"- Ticket: `{document['ticket']}`", + f"- Repository: `{document['repository']}`", + f"- Owner route: `{document['ownerRoute']}`", + f"- Status: `{document['status']}`", + f"- Intent digest: `{report['intentDigest']}`", + "- Authority: accepted intent and deterministic governance; LLM/todo2code are advisory.", + "", + "## Objective", + "", + document["objective"]["outcome"], + "", + "### Non-goals", + "", + ] + lines.extend(f"- {item}" for item in document["objective"]["nonGoals"]) + lines.extend(["", "### Constraints", ""]) + lines.extend(f"- {item}" for item in document["objective"]["constraints"]) + lines.extend(["", "## Findings", ""]) + _render_findings(document, criteria, lines) + lines.extend(["## Required planning order", ""]) + actions = {item["id"]: item for item in document["actions"]} + for index, action_id in enumerate(document["llmGuidance"]["planningOrder"], start=1): + action = actions[action_id] + paths = ", ".join(f"`{path}`" for path in action["paths"]) + lines.append( + f"{index}. [{action_id}/{action['operation']}] {action['description']} Paths: {paths}." + ) + lines.extend(["", "## LLM guardrails", "", f"Role: {document['llmGuidance']['role']}", ""]) + lines.append("Must preserve:") + lines.extend(f"- {item}" for item in document["llmGuidance"]["mustPreserve"]) + lines.append("") + lines.append("Forbidden assumptions:") + lines.extend( + f"- {item}" for item in document["llmGuidance"]["forbiddenAssumptions"] + ) + analysis = document.get("advisoryAnalysis") + if isinstance(analysis, dict): + lines.extend(["", "## Digest-bound todo2code hints (ADVISORY)", ""]) + lines.extend(f"- {hint}" for hint in analysis.get("llmHints", [])) + return "\n".join(lines).rstrip() + "\n" + + +def render_todo2code(document: dict[str, Any]) -> tuple[str, str]: + report = _require_valid(document, ready=True) + if document["todo2code"]["enabled"] is not True: + raise ValueError("todo2code projection is disabled by the accepted intent") + criteria = _criterion_map(document) + findings = {item["id"]: item for item in document["findings"]} + actions = {item["id"]: item for item in document["actions"]} + verifications = {item["id"]: item for item in document["verifications"]} + task_lines = [ + f"# Refactoring task {document['intentId']}", + "", + *_projection_headings(document, report["intentDigest"]), + "## required changes", + ] + todo_lines = [ + f"# TODO for {document['intentId']}", + "", + *_projection_headings(document, report["intentDigest"]), + "## required changes", + ] + for action_id in document["llmGuidance"]["planningOrder"]: + line = _todo2code_action_line( + document, + actions[action_id], + findings, + criteria, + verifications, + report["intentDigest"], + ) + task_lines.extend([f"### action: {action_id}", line]) + todo_lines.append(f"- [ ] {line}") + return "\n".join(task_lines).rstrip() + "\n", "\n".join(todo_lines).rstrip() + "\n" + + +class ProjectionError(ValueError): + """A declared todo2code projection is unsafe, missing or stale.""" + + +def _declared_projection_paths( + document: dict[str, Any], root: Path +) -> tuple[Path, Path]: + root = root.resolve() + if not root.is_dir(): + raise ProjectionError(f"repository root is not a directory: {root}") + paths: list[Path] = [] + for field in ("taskPath", "todoPath"): + relative = document["todo2code"][field] + candidate = (root / relative).resolve() + try: + candidate.relative_to(root) + except ValueError as error: + raise ProjectionError( + f"todo2code.{field} escapes repository root: {relative}" + ) from error + paths.append(candidate) + return paths[0], paths[1] + + +def verify_todo2code(document: dict[str, Any], root: Path) -> dict[str, Any]: + task, todo = render_todo2code(document) + task_path, todo_path = _declared_projection_paths(document, root) + issues: list[dict[str, str]] = [] + for field, path, expected in ( + ("todo2code.taskPath", task_path, task), + ("todo2code.todoPath", todo_path, todo), + ): + if not path.is_file(): + issues.append(_issue(PROJECTION_CODE, field, f"projection is missing: {path}")) + continue + try: + actual = path.read_bytes() + except OSError as error: + issues.append(_issue(PROJECTION_CODE, field, f"cannot read projection: {error}")) + continue + expected_bytes = expected.encode("utf-8") + if actual != expected_bytes: + issues.append( + _issue( + PROJECTION_CODE, + field, + "projection bytes differ from accepted intent " + f"(expected sha256={hashlib.sha256(expected_bytes).hexdigest()}, " + f"actual sha256={hashlib.sha256(actual).hexdigest()})", + ) + ) + return { + "schema": "new-project.remediation-projection-verification/v1", + "intentId": document["intentId"], + "intentDigest": intent_digest(document), + "ok": not issues, + "issues": issues, + } + + +def _analysis_finding( + code: str, + severity: str, + message: str, + references: list[str], + hint: str, +) -> dict[str, Any]: + return { + "code": code, + "severity": severity, + "message": message, + "references": list(dict.fromkeys(references)), + "llmHint": hint, + } + + +def _plan_corpus(plan: dict[str, Any]) -> str: + return json.dumps(plan, ensure_ascii=False, sort_keys=True).lower() + + +def _plan_paths(plan: dict[str, Any]) -> list[str]: + target = plan.get("target") + if not isinstance(target, dict) or not isinstance(target.get("paths"), list): + return [] + return [path for path in target["paths"] if isinstance(path, str)] + + +def _record_ids(value: Any) -> set[str]: + if not isinstance(value, list): + return set() + return {item for item in value if isinstance(item, str) and item} + + +def _projection_record_ids( + document: dict[str, Any], graph: dict[str, Any] +) -> set[str]: + if graph.get("schemaVersion") != T2C_GRAPH_SCHEMA or not isinstance( + graph.get("records"), list + ): + raise ValueError(f"graph must use {T2C_GRAPH_SCHEMA}") + expected_paths = { + str(PurePosixPath(document["todo2code"]["taskPath"])), + str(PurePosixPath(document["todo2code"]["todoPath"])), + } + ids_by_path: dict[str, set[str]] = {path: set() for path in expected_paths} + for record in graph["records"]: + if not isinstance(record, dict) or not isinstance(record.get("id"), str): + continue + source = record.get("source") + if not isinstance(source, dict) or not isinstance(source.get("path"), str): + continue + source_path = str(PurePosixPath(source["path"])) + if source_path in ids_by_path: + ids_by_path[source_path].add(record["id"]) + missing = sorted(path for path, record_ids in ids_by_path.items() if not record_ids) + if missing: + raise ValueError( + "todo2code graph has no records for declared projection(s): " + + ", ".join(missing) + ) + return set().union(*ids_by_path.values()) + + +def _analyze_plan_scope(plan_items, allowed, forbidden, findings): + for plan in plan_items: + plan_id = str(plan.get("id", "unknown-plan")) + for path in _plan_paths(plan): + if not _safe_path(path) or not _matches(path, allowed) or _matches(path, forbidden): + findings.append( + _analysis_finding( + "T2C_SCOPE_EXPANSION", + "BLOCKING", + f"todo2code plan {plan_id} targets path outside accepted scope: {path}", + [plan_id, path], + f"Remove `{path}` from the refactoring plan or obtain a fresh bounded intent before implementation.", + ) + ) + changes = plan.get("changes", []) + if isinstance(changes, list): + for change in changes: + if not isinstance(change, dict) or change.get("action") != "delete": + continue + path = str(change.get("path", "")) + # The closed remediation DSL has no typed file-delete operation. + # Risk/authorization labels do not define additional effects. + findings.append( + _analysis_finding( + "T2C_UNAUTHORIZED_DELETION", + "BLOCKING", + f"todo2code proposes file deletion unsupported by the remediation DSL: {path}", + [plan_id, path], + "Preserve the file or use read-only triage. File deletion requires a separately supported typed operation and bounded authority; risk metadata alone cannot grant it.", + ) + ) + + + +def _analyze_finding_coverage(finding_by_id, plan_corpora, plan_items, findings): + for finding_id, finding in finding_by_id.items(): + if finding.get("status") == "DEFERRED": + continue + code = finding["diagnostic"]["code"].lower() + affected = [path.lower() for path in finding.get("affectedPaths", [])] + matched = [ + plan_id + for plan_id, corpus in plan_corpora.items() + if finding_id.lower() in corpus + or code in corpus + or any(path != "unresolved:agent" and path in corpus for path in affected) + ] + if not matched: + findings.append( + _analysis_finding( + "T2C_PLAN_GAP", + "REVIEW", + f"no todo2code plan is grounded in active finding {finding_id}", + [finding_id, finding["diagnostic"]["code"]], + f"Add an explicit action/path link for {finding_id}; do not guess a path from the diagnostic name.", + ) + ) + continue + expected_priority = finding["priority"] + for plan in plan_items: + plan_id = str(plan.get("id", "unknown-plan")) + if plan_id not in matched: + continue + plan_priority = plan.get("priority") + if plan_priority in PRIORITIES and int(plan_priority[1]) > int(expected_priority[1]): + findings.append( + _analysis_finding( + "T2C_PRIORITY_DRIFT", + "REVIEW", + f"plan {plan_id} lowers {finding_id} from {expected_priority} to {plan_priority}", + [finding_id, plan_id], + f"Preserve the accepted {expected_priority} priority or record why a fresh intent changes it.", + ) + ) + + + +def _analyze_diagnostics(diagnostics, projection_record_ids, findings): + for diagnostic in diagnostics["diagnostics"]: + if not isinstance(diagnostic, dict): + continue + if not (_record_ids(diagnostic.get("recordIds")) & projection_record_ids): + continue + code = diagnostic.get("code") + diagnostic_id = str(diagnostic.get("id", "unknown-diagnostic")) + action = str(diagnostic.get("suggestedAction", "Review the todo2code diagnostic.")) + detail = str(diagnostic.get("detail", diagnostic.get("title", code or "diagnostic"))) + if code == "AMBIGUOUS_REQUIREMENT": + findings.append( + _analysis_finding( + "T2C_AMBIGUOUS_INTENT", + "REVIEW", + detail, + [diagnostic_id], + action, + ) + ) + elif code in {"HUMAN_AGENT_CONFLICT", "HUMAN_COMMUNICATION_CONFLICT"}: + findings.append( + _analysis_finding( + "T2C_CONFLICT", + "BLOCKING", + detail, + [diagnostic_id], + action, + ) + ) + + + +def _projected_plan_items(diagnostics, plans, projection_record_ids): + if diagnostics.get("schemaVersion") != T2C_DIAGNOSTICS_SCHEMA or not isinstance( + diagnostics.get("diagnostics"), list + ): + raise ValueError(f"diagnostics must use {T2C_DIAGNOSTICS_SCHEMA}") + if plans.get("schemaVersion") != T2C_PLAN_SET_SCHEMA or not isinstance( + plans.get("plans"), list + ): + raise ValueError(f"plans must use {T2C_PLAN_SET_SCHEMA}") + all_plan_items = [item for item in plans["plans"] if isinstance(item, dict)] + for index, plan in enumerate(all_plan_items): + if plan.get("schemaVersion") != T2C_PLAN_SCHEMA: + raise ValueError(f"plans[{index}] must use {T2C_PLAN_SCHEMA}") + plan_items = [ + plan + for plan in all_plan_items + if _record_ids( + plan.get("evidence", {}).get("recordIds") + if isinstance(plan.get("evidence"), dict) + else None + ) + & projection_record_ids + ] + + return plan_items + + +def _analyze_criterion_coverage(document, plan_corpora, findings): + all_plan_text = "\n".join(plan_corpora.values()) + for criterion in document["acceptanceCriteria"]: + if criterion["id"].lower() not in all_plan_text and criterion["statement"].lower() not in all_plan_text: + findings.append( + _analysis_finding( + "T2C_CRITERION_GAP", + "REVIEW", + f"todo2code plans do not preserve acceptance criterion {criterion['id']}", + [criterion["id"]], + f"Add `{criterion['id']}` and its deterministic verification to the implementation plan.", + ) + ) + + + +def analyze_todo2code( + document: dict[str, Any], + graph: dict[str, Any], + diagnostics: dict[str, Any], + plans: dict[str, Any], +) -> tuple[dict[str, Any], bool]: + _require_valid(document, ready=True) + projection_record_ids = _projection_record_ids(document, graph) + plan_items = _projected_plan_items(diagnostics, plans, projection_record_ids) + scope = document["scope"] + allowed = scope["allowedPaths"] + forbidden = scope["forbiddenPaths"] + finding_by_id = {item["id"]: item for item in document["findings"]} + plan_corpora = {str(plan.get("id", f"plan-{index}")): _plan_corpus(plan) for index, plan in enumerate(plan_items)} + findings: list[dict[str, Any]] = [] + + _analyze_plan_scope(plan_items, allowed, forbidden, findings) + _analyze_finding_coverage(finding_by_id, plan_corpora, plan_items, findings) + _analyze_criterion_coverage(document, plan_corpora, findings) + _analyze_diagnostics(diagnostics, projection_record_ids, findings) + unique_findings: list[dict[str, Any]] = [] + seen: set[bytes] = set() + for finding in findings: + key = _canonical(finding) + if key not in seen: + unique_findings.append(finding) + seen.add(key) + llm_hints = list(dict.fromkeys(item["llmHint"] for item in unique_findings)) + runtime_version = plans.get("generation", {}).get("runtimeVersion") + if not isinstance(runtime_version, str) or not runtime_version: + runtime_version = "unresolved-version" + + result = deepcopy(document) + result["status"] = "ANALYZED" + result["advisoryAnalysis"] = { + "authority": "ADVISORY", + "producer": { + "name": "todo2code", + "version": runtime_version, + "mode": "deterministic", + }, + "analyzedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "intentDigest": intent_digest(document), + "graphDigest": _digest(graph), + "diagnosticsDigest": _digest(diagnostics), + "plansDigest": _digest(plans), + "projectionRecordIds": sorted(projection_record_ids), + "planIds": [str(plan.get("id")) for plan in plan_items if plan.get("id")], + "findings": unique_findings, + "llmHints": llm_hints, + } + validation = validate_document(result) + if not validation["ok"]: + raise ValueError(json.dumps(validation, ensure_ascii=False, indent=2)) + blocking = any(item["severity"] == "BLOCKING" for item in unique_findings) + return result, blocking + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + mode = path.stat().st_mode & 0o777 if path.exists() else 0o644 + os.chmod(temporary, mode) + os.replace(temporary, path) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def _print_validation(report: dict[str, Any], output_format: str) -> None: + if output_format == "json": + print(json.dumps(report, ensure_ascii=False, indent=2)) + return + print( + f"remediation-intent: {report['findings']} findings, " + f"{report['actions']} actions, {len(report['errors'])} errors, " + f"{len(report['warnings'])} warnings" + ) + for issue in [*report["errors"], *report["warnings"]]: + print(f"{issue['code']}: {issue['path']}: {issue['message']}") + + +def _write_todo2code_projection(document, args): + task, todo = render_todo2code(document) + if (args.task_out is None) != (args.todo_out is None): + raise ProjectionError( + "--task-out and --todo-out must be supplied together or omitted together" + ) + task_path, todo_path = ( + (args.task_out, args.todo_out) + if args.task_out is not None + else _declared_projection_paths(document, args.root) + ) + _write(task_path, task) + _write(todo_path, todo) + return 0 + + +def _verify_todo2code_command(document, args): + report = verify_todo2code(document, args.root) + if args.format == "json": + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + state = "PASS" if report["ok"] else "FAIL" + print( + f"remediation-todo2code-projection: {state}; " + f"{len(report['issues'])} issue(s)" + ) + for issue in report["issues"]: + print(f"{issue['code']}: {issue['path']}: {issue['message']}") + return 0 if report["ok"] else 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + validate_parser = subparsers.add_parser("validate", help="validate one remediation intent") + validate_parser.add_argument("intent", type=Path) + validate_parser.add_argument("--format", choices=("text", "json"), default="text") + + digest_parser = subparsers.add_parser("digest", help="print the authority-bearing intent digest") + digest_parser.add_argument("intent", type=Path) + + llm_parser = subparsers.add_parser("render-llm", help="render a canonical LLM planning brief") + llm_parser.add_argument("intent", type=Path) + llm_parser.add_argument("--out", type=Path) + + todo_parser = subparsers.add_parser( + "render-todo2code", help="render deterministic todo2code task and TODO inputs" + ) + todo_parser.add_argument("intent", type=Path) + todo_parser.add_argument("--root", type=Path, default=Path(".")) + todo_parser.add_argument("--task-out", type=Path) + todo_parser.add_argument("--todo-out", type=Path) + + verify_parser = subparsers.add_parser( + "verify-todo2code", help="verify declared todo2code projections byte-for-byte" + ) + verify_parser.add_argument("intent", type=Path) + verify_parser.add_argument("--root", type=Path, default=Path(".")) + verify_parser.add_argument("--format", choices=("text", "json"), default="text") + + analyze_parser = subparsers.add_parser( + "analyze-todo2code", help="bind todo2code diagnostics/plans as an advisory overlay" + ) + analyze_parser.add_argument("intent", type=Path) + analyze_parser.add_argument("--graph", type=Path, required=True) + analyze_parser.add_argument("--diagnostics", type=Path, required=True) + analyze_parser.add_argument("--plans", type=Path, required=True) + analyze_parser.add_argument("--out", type=Path, required=True) + + args = parser.parse_args() + try: + document = _load_json(args.intent) + if args.command == "validate": + report = validate_document(document) + _print_validation(report, args.format) + return 0 if report["ok"] else 1 + if args.command == "digest": + _require_valid(document) + print(intent_digest(document)) + return 0 + if args.command == "render-llm": + content = render_llm(document) + if args.out: + _write(args.out, content) + else: + print(content, end="") + return 0 + if args.command == "render-todo2code": + return _write_todo2code_projection(document, args) + if args.command == "verify-todo2code": + return _verify_todo2code_command(document, args) + if args.command == "analyze-todo2code": + result, blocking = analyze_todo2code( + document, + _load_json(args.graph), + _load_json(args.diagnostics), + _load_json(args.plans), + ) + _write(args.out, json.dumps(result, ensure_ascii=False, indent=2) + "\n") + if blocking: + print( + f"{T2C_CODE}: todo2code analysis contains blocking inconsistencies", + file=sys.stderr, + ) + return 1 if blocking else 0 + except ProjectionError as error: + print(f"{PROJECTION_CODE}: {error}", file=sys.stderr) + return 2 + except ValueError as error: + print(f"{MALFORMED_CODE}: {error}", file=sys.stderr) + return 2 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/repository_policy.py b/.governance/repository_policy.py new file mode 100755 index 0000000..118b9fd --- /dev/null +++ b/.governance/repository_policy.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 +"""Repository delivery profiles and external ticket adapters. + +The adoption profile (which standards are installed) and the delivery profile +(how a repository records and publishes work) are separate contracts. This +module contains the small, host-agnostic part shared by the managed gate and +the pre-commit hook. +""" + +from __future__ import annotations + +import json +import re +import argparse +import subprocess +from pathlib import Path +from typing import Any + + +PROFILES = { + "ticket-worktree": { + "deliveryMode": "ticket-branch", + "checkout": "linked-worktree", + "backend": "files", + "enforcement": "enforce", + }, + "main-only-planfile": { + "deliveryMode": "main-only", + "checkout": "primary", + "backend": "planfile", + "enforcement": "enforce", + }, + "main-only-files": { + "deliveryMode": "main-only", + "checkout": "primary", + "backend": "files", + "enforcement": "enforce", + }, + "protected-pr": { + "deliveryMode": "ticket-branch", + "checkout": "linked-worktree", + "backend": "files", + "enforcement": "enforce", + }, + "local-audit": { + "deliveryMode": "main-only", + "checkout": "primary", + "backend": "files", + "enforcement": "audit", + }, +} + + +def default_policy() -> dict[str, Any]: + return { + "profile": "ticket-worktree", + "enforcement": "enforce", + "delivery": { + "mode": "ticket-branch", + "checkout": "linked-worktree", + "targetBranches": ["main"], + }, + "tickets": { + "backend": "files", + "idPattern": r"^ticket-[0-9]{3,}$", + "referenceRequired": True, + }, + } + + +def selected_policy(manifest: dict[str, Any]) -> dict[str, Any]: + value = manifest.get("repositoryPolicy") + if value is None: + return default_policy() + return value + + +def relative_path_error(value: Any, *, pattern: bool = False) -> str | None: + """Reject paths that can escape the repository or hide a traversal.""" + if not isinstance(value, str) or not value or "\x00" in value: + return "path must be a non-empty string" + normalized = value.replace("\\", "/") + if normalized.startswith("/") or re.match(r"^[A-Za-z]:/", normalized): + return "path must be repository-relative" + parts = normalized.split("/") + if any(part in {"", ".", ".."} for part in parts): + return "path contains an unsafe component" + if not pattern and any(char in normalized for char in "*?["): + return "exact path must not contain glob metacharacters" + return None + + +def _read_index_bytes(root: Path, raw_path: str) -> bytes | None: + """Read the path that will be committed, not the mutable working tree.""" + result = subprocess.run( + ["git", "show", f":{raw_path}"], + cwd=root, + check=False, + capture_output=True, + ) + if result.returncode != 0: + return None + return result.stdout + + +def _index_has_symlink(root: Path, raw_path: str) -> bool: + result = subprocess.run( + ["git", "ls-files", "--stage", "--", raw_path], + cwd=root, + check=False, + capture_output=True, + text=True, + ) + return any(line.split(maxsplit=1)[0] == "120000" for line in result.stdout.splitlines() if line) + + +def _working_tree_has_symlink(root: Path, raw_path: str) -> bool: + current = root + for part in Path(raw_path).parts: + current /= part + if current.is_symlink(): + return True + return False + + +def _snapshot_bytes(root: Path, raw_path: str, *, staged: bool) -> bytes | None: + if staged: + return _read_index_bytes(root, raw_path) + path = root / raw_path + try: + return path.read_bytes() + except (OSError, UnicodeError): + return None + + +def _safe_snapshot_path(root: Path, raw_path: str, *, staged: bool) -> str | None: + error = relative_path_error(raw_path) + if error: + return error + if _index_has_symlink(root, raw_path) if staged else _working_tree_has_symlink(root, raw_path): + return "path contains a symlink" + try: + repository = root.resolve() + resolved = (root / raw_path).resolve(strict=False) + except OSError as error: + return f"path cannot be resolved: {error}" + if resolved != repository and repository not in resolved.parents: + return "path resolves outside the repository" + return None + + +def policy_error(value: Any) -> str | None: + if not isinstance(value, dict): + return "repositoryPolicy must be an object" + fields = {"profile", "enforcement", "delivery", "tickets"} + if set(value) != fields: + return "repositoryPolicy fields must be profile, enforcement, delivery and tickets" + profile = value.get("profile") + if profile not in PROFILES: + return f"unknown repository delivery profile: {profile}" + if value.get("enforcement") not in {"audit", "enforce"}: + return "repositoryPolicy enforcement must be audit or enforce" + delivery = value.get("delivery") + if not isinstance(delivery, dict) or set(delivery) != {"mode", "checkout", "targetBranches"}: + return "repositoryPolicy.delivery fields are invalid" + if delivery.get("mode") not in {"ticket-branch", "main-only"}: + return "repositoryPolicy.delivery.mode is invalid" + if delivery.get("checkout") not in {"linked-worktree", "primary"}: + return "repositoryPolicy.delivery.checkout is invalid" + branches = delivery.get("targetBranches") + if not isinstance(branches, list) or not branches or len(branches) != len(set(branches)): + return "repositoryPolicy.delivery.targetBranches must be unique and non-empty" + if not all(isinstance(item, str) and item and "/" not in item for item in branches): + return "repositoryPolicy.delivery.targetBranches contains an invalid branch" + tickets = value.get("tickets") + if not isinstance(tickets, dict): + return "repositoryPolicy.tickets must be an object" + allowed = {"backend", "idPattern", "referenceRequired", "adapterPath", "sourcePaths"} + if set(tickets) - allowed or not {"backend", "idPattern", "referenceRequired"} <= set(tickets): + return "repositoryPolicy.tickets fields are invalid" + if tickets.get("backend") not in {"files", "sqlite", "planfile"}: + return "repositoryPolicy.tickets.backend is invalid" + if not isinstance(tickets.get("idPattern"), str) or not tickets["idPattern"]: + return "repositoryPolicy.tickets.idPattern must be non-empty" + try: + re.compile(tickets["idPattern"]) + except re.error as error: + return f"repositoryPolicy.tickets.idPattern is invalid: {error}" + if not isinstance(tickets.get("referenceRequired"), bool): + return "repositoryPolicy.tickets.referenceRequired must be boolean" + for field in ("adapterPath",): + if field in tickets and relative_path_error(tickets[field]) is not None: + return f"repositoryPolicy.tickets.{field} must be a safe repository-relative path" + if "sourcePaths" in tickets: + paths = tickets["sourcePaths"] + if ( + not isinstance(paths, list) + or not paths + or any(relative_path_error(item) is not None for item in paths) + ): + return "repositoryPolicy.tickets.sourcePaths must be a safe non-empty path list" + preset = PROFILES[profile] + if ( + value.get("delivery", {}).get("mode") != preset["deliveryMode"] + or value.get("delivery", {}).get("checkout") != preset["checkout"] + ): + return f"repositoryPolicy profile {profile} does not match its delivery mode" + if value.get("tickets", {}).get("backend") != preset["backend"]: + return f"repositoryPolicy profile {profile} does not match its ticket backend" + if profile != "local-audit" and value.get("enforcement") != preset["enforcement"]: + return f"repositoryPolicy profile {profile} must use enforcement={preset['enforcement']}" + if tickets.get("backend") == "planfile" and "adapterPath" not in tickets: + return "planfile profile requires tickets.adapterPath" + return None + + +def load_adapter( + root: Path, + manifest: dict[str, Any], + *, + staged: bool = False, +) -> tuple[dict[str, Any] | None, str | None]: + policy = selected_policy(manifest) + tickets = policy.get("tickets", {}) + if tickets.get("backend") != "planfile": + return None, None + raw_path = tickets.get("adapterPath") + if not isinstance(raw_path, str) or relative_path_error(raw_path) is not None: + return None, "planfile adapterPath is missing or unsafe" + path_error = _safe_snapshot_path(root, raw_path, staged=staged) + if path_error: + return None, f"planfile adapterPath is unsafe: {path_error}" + raw_adapter = _snapshot_bytes(root, raw_path, staged=staged) + if raw_adapter is None: + return None, "planfile ticket adapter is unreadable from the selected snapshot" + try: + adapter = json.loads(raw_adapter.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as error: + return None, f"planfile ticket adapter is unreadable: {error}" + error = adapter_error(adapter, root, raw_path, tickets, staged=staged) + return (None, error) if error else (adapter, None) + + +def adapter_error( + adapter: Any, + root: Path, + raw_path: str, + tickets: dict[str, Any], + *, + staged: bool = False, +) -> str | None: + fields = {"schema", "ticket", "summary", "status", "workflow", "workstream", "allowedPaths", "forbiddenPaths", "sourcePaths"} + if not isinstance(adapter, dict) or set(adapter) != fields: + return "external ticket adapter fields are invalid" + if adapter.get("schema") != "wellmanifest.external-ticket-adapter/v1": + return "unsupported external ticket adapter schema" + ticket = adapter.get("ticket") + if not isinstance(ticket, str) or re.fullmatch(tickets["idPattern"], ticket) is None: + return "external ticket adapter has an invalid ticket ID" + if not isinstance(adapter.get("summary"), str) or not adapter["summary"].strip(): + return "external ticket adapter summary is blank" + if adapter.get("status") not in {"IN_PROGRESS"} or adapter.get("workflow") not in {"EDIT", "VALIDATION", "PUBLICATION"}: + return "external ticket adapter is not active" + if not isinstance(adapter.get("workstream"), str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", adapter["workstream"]): + return "external ticket adapter workstream is invalid" + for field in ("allowedPaths", "forbiddenPaths", "sourcePaths"): + values = adapter.get(field) + if not isinstance(values, list) or len(values) != len(set(values)): + return f"external ticket adapter {field} is invalid" + for item in values: + error = relative_path_error(item, pattern=field != "sourcePaths") + if error: + return f"external ticket adapter {field} is invalid: {error}" + if not adapter["allowedPaths"]: + return "external ticket adapter allowedPaths is empty" + for source in adapter["sourcePaths"]: + path_error = _safe_snapshot_path(root, source, staged=staged) + if path_error: + return f"external ticket source is unsafe: {source}: {path_error}" + raw_source = _snapshot_bytes(root, source, staged=staged) + if raw_source is None: + return f"external ticket source is missing: {source}" + observed = None + try: + for line in raw_source.decode("utf-8").splitlines(): + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + event = value.get("event", value) if isinstance(value, dict) else {} + if isinstance(event, dict) and event.get("ticket_id") == ticket: + observed = str(event.get("status", "")) + except (OSError, UnicodeError) as error: + return f"external ticket source is unreadable: {error}" + if observed is None: + return f"external ticket source does not contain {ticket}: {source}" + if observed.lower() in {"done", "cancelled", "canceled", "closed"}: + return f"external ticket {ticket} is terminal in {source}" + if _matches(raw_path, adapter["allowedPaths"]) or _matches(raw_path, adapter["forbiddenPaths"]): + return "external ticket adapter cannot own itself" + return None + + +def policy_is_main_only(manifest: dict[str, Any]) -> bool: + return selected_policy(manifest)["delivery"]["mode"] == "main-only" + + +def policy_requires_reference(manifest: dict[str, Any]) -> bool: + return bool(selected_policy(manifest)["tickets"].get("referenceRequired")) + + +def _matches(path: str, patterns: list[str]) -> bool: + from fnmatch import fnmatchcase + path_parts = path.replace("\\", "/").strip("/").split("/") + + def match_pattern(pattern: str) -> bool: + pattern_parts = pattern.replace("\\", "/").strip("/").split("/") + memo: dict[tuple[int, int], bool] = {} + + def visit(path_index: int, pattern_index: int) -> bool: + key = (path_index, pattern_index) + if key in memo: + return memo[key] + if pattern_index == len(pattern_parts): + result = path_index == len(path_parts) + elif pattern_parts[pattern_index] == "**": + result = visit(path_index, pattern_index + 1) or ( + path_index < len(path_parts) and visit(path_index + 1, pattern_index) + ) + else: + result = ( + path_index < len(path_parts) + and fnmatchcase(path_parts[path_index], pattern_parts[pattern_index]) + and visit(path_index + 1, pattern_index + 1) + ) + memo[key] = result + return result + + return visit(0, 0) + + return any(match_pattern(pattern) for pattern in patterns) + + +def check_staged(root: Path) -> tuple[bool, str]: + manifest_path = ".governance/manifest.json" + raw_manifest = _read_index_bytes(root, manifest_path) + if raw_manifest is None: + return False, "repository manifest is missing from the staged snapshot" + try: + manifest = json.loads(raw_manifest.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as error: + return False, f"repository manifest is unreadable: {error}" + error = policy_error(selected_policy(manifest)) + if error: + return False, error + policy = selected_policy(manifest) + if policy["enforcement"] == "audit": + return True, "repository delivery profile is audit-only" + branch = subprocess.run( + ["git", "symbolic-ref", "--short", "HEAD"], cwd=root, + check=False, capture_output=True, text=True, + ).stdout.strip() + if policy["delivery"]["mode"] == "main-only" and branch not in policy["delivery"]["targetBranches"]: + return False, f"main-only profile requires one of {policy['delivery']['targetBranches']}, got {branch or 'detached HEAD'}" + if policy["tickets"]["backend"] == "planfile": + adapter, error = load_adapter(root, manifest, staged=True) + if error or adapter is None: + return False, error or "external ticket adapter is missing" + raw = subprocess.run( + [ + "git", "diff", "--cached", "--name-only", "-z", "--no-renames", + "--diff-filter=ACDMRTUXB", "--", + ], + cwd=root, check=False, capture_output=True, + ).stdout.decode("utf-8", "surrogateescape") + changed = [item for item in raw.split("\0") if item] + adapter_path = str(policy["tickets"]["adapterPath"]) + if adapter_path in changed: + previous = subprocess.run( + ["git", "show", f"HEAD:{adapter_path}"], + cwd=root, check=False, capture_output=True, + ) + if previous.returncode == 0 and previous.stdout != _read_index_bytes(root, adapter_path): + return False, "ticket adapter changes must be committed separately from delivery files" + material_policy_path = ".governance/manifest.json" + manifest_exists_in_head = subprocess.run( + ["git", "cat-file", "-e", f"HEAD:{material_policy_path}"], + cwd=root, + check=False, + capture_output=True, + ).returncode == 0 + if ( + manifest_exists_in_head + and material_policy_path in changed + and any(path not in {adapter_path, material_policy_path} for path in changed) + ): + return False, "repository delivery policy cannot change with delivery files" + outside = [ + path for path in changed + if path not in {adapter_path, material_policy_path} + and (not _matches(path, adapter["allowedPaths"]) or _matches(path, adapter["forbiddenPaths"])) + ] + if outside: + return False, "staged paths fall outside the active external ticket scope: " + ", ".join(outside) + return True, "repository delivery profile is valid" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".") + parser.add_argument("--staged", action="store_true") + parser.add_argument("--profile", action="store_true") + args = parser.parse_args() + root = Path(args.root).resolve() + if args.profile: + try: + manifest = json.loads((root / ".governance/manifest.json").read_text(encoding="utf-8")) + print(selected_policy(manifest)["profile"]) + return 0 + except (OSError, UnicodeError, json.JSONDecodeError, KeyError) as error: + print(error) + return 1 + if args.staged: + ok, message = check_staged(root) + else: + try: + manifest = json.loads((root / ".governance/manifest.json").read_text(encoding="utf-8")) + error = policy_error(selected_policy(manifest)) + ok, message = error is None, error or "repository delivery profile is valid" + except (OSError, UnicodeError, json.JSONDecodeError) as error: + ok, message = False, str(error) + print(message) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/required-checks.json b/.governance/required-checks.json new file mode 100644 index 0000000..1b0016f --- /dev/null +++ b/.governance/required-checks.json @@ -0,0 +1,19 @@ +{ + "repository": "semcod/code2graph", + "requiredChecks": [ + { + "name": "test", + "workflowFile": ".github/workflows/ci.yml" + }, + { + "name": "governance / remote lifecycle", + "workflowFile": ".github/workflows/new-project-governance.yml" + }, + { + "name": "governance / enforce", + "workflowFile": ".github/workflows/new-project-governance.yml" + } + ], + "schema": "new-project.required-checks/v1", + "version": 1 +} diff --git a/.governance/required-checks.schema.json b/.governance/required-checks.schema.json new file mode 100644 index 0000000..470b49e --- /dev/null +++ b/.governance/required-checks.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/new-project/required-checks/v1", + "title": "new-project required checks instance", + "description": "Per-repository required-check declaration. The hub instance may keep workflowFile + requiredCheckNames. Adopters with two workflows use requiredChecks.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "repository"], + "properties": { + "schema": { "const": "new-project.required-checks/v1" }, + "version": { "type": "integer", "minimum": 1 }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "workflowFile": { "type": "string", "minLength": 1 }, + "requiredCheckNames": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "requiredChecks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "workflowFile"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "workflowFile": { "type": "string", "minLength": 1 } + } + } + }, + "circularGovernanceChecksIgnoredByValidator": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "externalConsumers": { + "type": "array", + "items": { "type": "object" } + } + }, + "oneOf": [ + { + "required": ["workflowFile", "requiredCheckNames"], + "not": { "required": ["requiredChecks"] } + }, + { + "required": ["requiredChecks"], + "not": { + "anyOf": [ + { "required": ["workflowFile"] }, + { "required": ["requiredCheckNames"] } + ] + } + } + ] +} diff --git a/.governance/reusable-workflows.schema.json b/.governance/reusable-workflows.schema.json new file mode 100644 index 0000000..41cdcf0 --- /dev/null +++ b/.governance/reusable-workflows.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/new-project/reusable-workflows/v1", + "title": "Reviewed offline reusable workflow source bindings", + "type": "object", + "additionalProperties": false, + "required": ["schema", "workflows"], + "properties": { + "schema": {"const": "new-project.reusable-workflows/v1"}, + "workflows": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["uses", "sourceFile", "sourceSha256"], + "properties": { + "uses": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\\.github/workflows/[A-Za-z0-9_.-]+\\.ya?ml@[0-9a-f]{40}$"}, + "sourceFile": {"type": "string", "pattern": "^\\.github/reusable-workflows/[A-Za-z0-9_.-]+\\.ya?ml$"}, + "sourceSha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + } + } + } + } +} diff --git a/.governance/seed_baseline.py b/.governance/seed_baseline.py new file mode 100755 index 0000000..0e78d0f --- /dev/null +++ b/.governance/seed_baseline.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Explicit native-adopter seed transaction; no product or remote authority. + +The caller supplies an external exact-state request under existing session +authorization. The installed adoption lock pins the named seed profile. The +allocator owns reservation/locking; the normal Git hook checks the prepared +transaction. This is a local accidental-writer boundary, not hostile-OS-user +authentication or trusted publication approval. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess + +SCHEMA = 'new-project.seed-baseline-request/v1' +PROFILE = 'seed-profile:new-project-native' +FIELDS = {'schema', 'root', 'repositoryRef', 'authorizationRef', 'createRepository', + 'execute', 'sourceRevision', 'lockSha256', 'seedProfileRef', + 'ownerActor', 'ownerSession', 'files'} +METADATA = {'README.md', 'VERSION', 'CHANGELOG.md', 'TODO.md', 'project/TICKETS.md', + '.gitignore', '.governance/manifest.json', '.governance/manifest.lock.json', + '.governance/required-checks.json'} +SECRET = re.compile(rb'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|' + rb'gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}') + + +class SeedError(ValueError): + pass + + +def require(condition, reason): + if not condition: + raise SeedError(reason) + + +def digest(raw): + return hashlib.sha256(raw).hexdigest() + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(',', ':')).encode() + + +def regular(path): + require(not any(p.is_symlink() for p in (path, *path.parents)), 'symlink_path') + require(path.is_file(), 'regular_file_required') + + +def load(path): + regular(path) + raw = path.read_bytes() + require(len(raw) <= 131072, 'document_too_large') + def pairs(items): + result = {} + for key, value in items: + require(key not in result, 'duplicate_json_key') + result[key] = value + return result + value = json.loads(raw, object_pairs_hook=pairs) + require(isinstance(value, dict), 'object_required') + return value + + +def git(root, *args, check=True, env=None): + clean = {k: v for k, v in (env or os.environ).items() if not k.startswith('GIT_')} + return subprocess.run(['git', '-C', str(root), *args], check=check, + capture_output=True, env=clean) + + +def stage(root): + result = {} + total = 0 + for row in git(root, 'ls-files', '--stage', '-z').stdout.split(b'\0'): + if not row: + continue + header, raw_name = row.split(b'\t', 1) + mode, oid, level = header.decode().split() + name = raw_name.decode() + path = PurePosixPath(name) + require(path.as_posix() == name and not path.is_absolute() + and '..' not in path.parts and not any(ord(c) < 32 for c in name), 'unsafe_name') + require(mode in ('100644', '100755') and level == '0', 'regular_stage_required') + regular(root / name) + raw = git(root, 'cat-file', 'blob', oid).stdout + total += len(raw) + require(total <= 16 * 1024 * 1024 and len(result) < 512, 'seed_size_limit') + require(raw == (root / name).read_bytes(), 'index_worktree_drift') + require(not SECRET.search(raw), 'secret_material') + result[name] = digest(raw) + return result + + +def context(root, request_path, actor, session, *, allowed_untracked=()): + require(root.is_absolute() and root == root.resolve(), 'canonical_root_required') + require(not any(p.is_symlink() for p in (root, *root.parents)), 'symlink_path') + require(request_path == request_path.resolve() and root not in request_path.parents, 'external_request_required') + request = load(request_path) + require(set(request) == FIELDS and request['schema'] == SCHEMA, 'request_shape') + require(request['root'] == str(root) and request['seedProfileRef'] == PROFILE, 'request_binding') + require(request['createRepository'] is True and request['execute'] is True + and re.fullmatch(r'authorization:session/[a-z0-9._/-]+', request['authorizationRef']), + 'explicit_new_repository_authority_required') + require(request['ownerActor'] == actor and request['ownerSession'] == session + and actor and session, 'writer_binding') + require(re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', request['repositoryRef']), 'repository_binding') + require(git(root, 'rev-parse', '--verify', 'HEAD', check=False).returncode != 0, 'head_must_be_unborn') + require(not git(root, 'for-each-ref', '--format=%(refname)').stdout.strip(), 'existing_ref') + require(not git(root, 'remote').stdout.strip(), 'remote_forbidden') + require((root / '.git').is_dir() and not (root / '.git').is_symlink(), 'primary_checkout_required') + require(git(root, 'config', '--get', 'core.hooksPath', check=False).stdout.strip() == b'.githooks' + and os.access(root / '.githooks/pre-commit', os.X_OK), 'managed_hook_required') + branch = git(root, 'symbolic-ref', '--short', 'HEAD').stdout.decode().strip() + manifest = load(root / '.governance/manifest.json') + require(manifest['delivery']['targetBranches'] == [branch], 'target_branch_required') + lock_path = root / '.governance/manifest.lock.json' + lock = load(lock_path) + require(digest(lock_path.read_bytes()) == request['lockSha256'], 'adoption_lock_drift') + pin = lock.get('standard', {}) + require(pin.get('id') == 'wellmanifest/new-project' + and pin.get('sourceRepository') == 'wellmanifest/new-project' + and pin.get('publicationStatus') == 'published' + and re.fullmatch('[a-f0-9]{40}', request['sourceRevision']) + and pin.get('sourceRevision') == request['sourceRevision'], 'published_profile_required') + managed = lock.get('managedFiles', {}) + mapping_path = root / '.governance/package-manifest.json' + require(managed.get('.governance/package-manifest.json') == digest(mapping_path.read_bytes()), + 'package_manifest_drift') + mapping = load(mapping_path) + allowed = METADATA | {row['target'] for row in mapping['files']} + require(set(managed) <= allowed, 'profile_managed_paths') + initial = request['files'] + require(isinstance(initial, dict) and set(managed) <= set(initial) <= allowed, + 'concrete_seed_allowlist_required') + require(all(re.fullmatch('[a-f0-9]{64}', value) for value in initial.values()), 'file_digest_required') + require(all(initial.get(name) == sha for name, sha in managed.items()), 'managed_pin_drift') + require(set(METADATA) <= set(initial), 'seed_prerequisites_required') + require(load(root / '.governance/required-checks.json')['repository'] == request['repositoryRef'], + 'check_repository_binding') + # Validate carrier paths before Git classifies a symlinked parent as an + # unrelated untracked path, and before any ticket contents are read. + for name in allowed_untracked: + regular(root / name) + untracked = set(git(root, 'ls-files', '--others', '-z').stdout.decode().split('\0')) - {''} + require(untracked <= set(allowed_untracked), 'untracked_path') + return request + + +def preflight(root, request_path, actor, session): + request = context(root, request_path, actor, session) + require(stage(root) == request['files'], 'exact_seed_stage_required') + require(not (root / '.git/new-project-seed-reservation.json').exists(), 'seed_already_reserved') + require(not (root / '.git/new-project-ticket-high-water').exists(), 'existing_ticket_reservation') + probe = subprocess.run(['python3', '-B', str(root / '.governance/worktree_path_check.py'), + 'feature-probe', '--from-worktree', str(root)], + check=True, capture_output=True) + require(json.loads(probe.stdout).get('supported') is True, 'relative_worktrees_required') + return request + + +def prepare(root, request_path, actor, session, ticket): + # Validate without changing files or the index. Only this allocator's two + # ticket carriers may be untracked; other context callers stay strict. + require(re.fullmatch('ticket-[0-9]{3,}', ticket), 'native_ticket_required') + carriers = [f'project/{ticket}/README.md', f'project/{ticket}/intent.json'] + request = context(root, request_path, actor, session, allowed_untracked=carriers) + readme, intent_path = (root / name for name in carriers) + + # The managed allocator has reserved this exact ID under its clone lock. + counter = root / '.git/new-project-ticket-high-water' + regular(counter) + require(counter.read_text().strip() == str(int(ticket.removeprefix('ticket-'))), 'native_reservation_required') + require((root / '.git/new-project-ticket-allocation.lock').is_dir(), 'allocator_lock_required') + reservation = root / '.git/new-project-seed-reservation.json' + require(not reservation.exists() and not reservation.is_symlink(), 'seed_already_reserved') + intent = load(intent_path) + require(intent['ticket'] == ticket and 'delivery' not in intent, 'unborn_ticket_binding') + seed_files = request['files'] + initial = stage(root) + require(set(initial) == set(seed_files), 'exact_seed_stage_required') + require(all(initial[name] == sha for name, sha in seed_files.items() + if name != 'project/TICKETS.md'), 'seed_content_drift') + text = readme.read_text() + require('SESSION_EXECUTION_AUTHORIZATION' not in text, 'unexpected_ticket_content') + text += '\nSESSION_EXECUTION_AUTHORIZATION: explicit new-repository seed request; no product implementation before the real baseline.\n' + carrier_bytes = [text.encode(), intent_path.read_bytes()] + require(not any(SECRET.search(raw) for raw in carrier_bytes), 'secret_material') + require(len(initial) + len(carriers) <= 512 + and sum((root / name).stat().st_size for name in initial) + + sum(map(len, carrier_bytes)) <= 16 * 1024 * 1024, 'seed_size_limit') + request_sha256 = digest(request_path.read_bytes()) + + # Mutate only after request, paths, reservation, intent and seed validation. + readme.write_text(text) + names = [*seed_files, *carriers] + git(root, 'add', '--', *names) + actual = stage(root) + require(set(actual) == set(names), 'seed_stage_expansion') + require(all(actual[name] == sha for name, sha in seed_files.items() + if name != 'project/TICKETS.md'), 'seed_content_drift') + record = {'requestSha256': request_sha256, 'ticket': ticket, + 'files': actual, 'actor': actor, 'session': session} + with reservation.open('x') as stream: + json.dump(record, stream, sort_keys=True) + return record + + +def hook_check(root, request_path, actor, session): + context(root, request_path, actor, session) + record = load(root / '.git/new-project-seed-reservation.json') + require(record['actor'] == actor and record['session'] == session + and record['requestSha256'] == digest(request_path.read_bytes()), 'prepared_request_drift') + require((root / '.git/new-project-ticket-allocation.lock').is_dir(), 'allocator_lock_required') + counter = root / '.git/new-project-ticket-high-water' + require(counter.read_text().strip() == str(int(record['ticket'].removeprefix('ticket-'))), 'reservation_drift') + require(stage(root) == record['files'], 'prepared_stage_drift') + return record + + +def commit(root, request_path, actor, session): + record = hook_check(root, request_path, actor, session) + env = dict(os.environ, NEW_PROJECT_SEED_REQUEST=str(request_path), + NEW_PROJECT_WRITER_ACTOR=actor, NEW_PROJECT_WRITER_SESSION=session) + result = git(root, 'commit', '-m', f'chore: establish governed baseline ({record["ticket"]})', + env=env, check=False) + require(result.returncode == 0, 'seed_commit_rejected: ' + + (result.stdout + result.stderr).decode(errors='replace')[:200]) + head = git(root, 'rev-parse', 'HEAD').stdout.decode().strip() + require(len(git(root, 'rev-list', '--all').stdout.splitlines()) == 1, 'one_root_commit_required') + receipt = {'schema': 'new-project.seed-baseline-receipt/v1', 'ticket': record['ticket'], + 'baselineSha': head, 'requestSha256': record['requestSha256'], + 'seedProfileRef': PROFILE, 'sourceAuthority': 'session-new-repository-only', + 'productSourcePresent': False, 'remoteEffects': 0} + path = root / '.subactor/receipts/seed-baseline.json' + path.parent.mkdir(parents=True, exist_ok=True) + with path.open('x') as stream: + json.dump(receipt, stream, sort_keys=True) + return receipt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('operation', choices=['preflight', 'prepare', 'hook-check', 'commit']) + parser.add_argument('--root', type=Path, required=True) + parser.add_argument('--request', type=Path, required=True) + parser.add_argument('--actor', default=os.environ.get('NEW_PROJECT_WRITER_ACTOR', '')) + parser.add_argument('--session', default=os.environ.get('NEW_PROJECT_WRITER_SESSION', '')) + parser.add_argument('--ticket') + parser.add_argument('--message-file', type=Path) + args = parser.parse_args() + try: + common = args.root.absolute(), args.request.absolute(), args.actor, args.session + result = (prepare(*common, args.ticket) if args.operation == 'prepare' + else {'preflight': preflight, 'hook-check': hook_check, 'commit': commit}[args.operation](*common)) + if args.message_file is not None: + require(args.operation == 'hook-check', 'message_check_requires_hook') + regular(args.message_file) + require(args.message_file.read_text().strip() == + f'chore: establish governed baseline ({result["ticket"]})', 'seed_message_binding') + print(json.dumps({'status': 'passed', 'operation': args.operation, + **({'receipt': result} if args.operation == 'commit' else {})})) + return 0 + except (ValueError, OSError, KeyError, TypeError, subprocess.SubprocessError) as error: + print(json.dumps({'status': 'refused', 'reason': str(error)[:250]})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.governance/snapshot-migration.schema.json b/.governance/snapshot-migration.schema.json new file mode 100644 index 0000000..0ce9a77 --- /dev/null +++ b/.governance/snapshot-migration.schema.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/snapshot-migration.schema.json", + "title": "One-time snapshot migration and external authorization", + "oneOf": [ + { + "$ref": "#/$defs/contract" + }, + { + "$ref": "#/$defs/authorization" + } + ], + "$defs": { + "contract": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "repository", + "baseSha", + "sourceSha", + "sourceTree", + "inventorySha256", + "authorizationRef" + ], + "properties": { + "schema": { + "const": "new-project.snapshot-migration/v1" + }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "baseSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "sourceSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "sourceTree": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "inventorySha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "authorizationRef": { + "type": "string", + "pattern": "^authorization:[A-Za-z0-9._/-]{1,200}$" + } + } + }, + "authorization": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "grantId", + "repository", + "ticket", + "branch", + "targetBranch", + "baseSha", + "contractSha256", + "intentSha256", + "implementationPaths", + "historicalTickets", + "maxUses", + "status" + ], + "properties": { + "schema": { + "const": "new-project.snapshot-migration-authorization/v1" + }, + "grantId": { + "type": "string", + "pattern": "^authorization:[A-Za-z0-9._/-]{1,200}$" + }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "ticket": { + "type": "string", + "pattern": "^ticket-[0-9]{3,}$" + }, + "branch": { + "type": "string", + "minLength": 1 + }, + "targetBranch": { + "type": "string", + "minLength": 1 + }, + "baseSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "contractSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "intentSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "implementationPaths": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "historicalTickets": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^ticket-[0-9]{3,}$" + } + }, + "maxUses": { + "const": 1 + }, + "status": { + "enum": [ + "reserved", + "consumed" + ] + } + } + } + } +} diff --git a/.governance/snapshot_allocation.py b/.governance/snapshot_allocation.py new file mode 100644 index 0000000..aa54294 --- /dev/null +++ b/.governance/snapshot_allocation.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Managed local snapshot allocation. Proposals grant no writer/merge authority. + +Materialization requires an externally pinned grant and an existing controller +lease, then validates the detached candidate before atomically creating its ref. +The standard never issues grants, acquires leases, pushes or approves a merge. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import hashlib +import importlib.util +import io +from contextlib import redirect_stdout +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from urllib.parse import urlparse + +sys.dont_write_bytecode = True +import governance_check as governance +import snapshot_migration as migration +import ticket_recovery as recovery +import work_start_check as start +from ticket_allocation import canonical_repository_ref, parse_config + +REQUEST_SCHEMA = 'new-project.snapshot-proposal-request/v1' +PROPOSAL_SCHEMA = 'new-project.snapshot-proposal/v1' +MATERIAL_SCHEMA = 'new-project.snapshot-materialization-request/v1' +REQUEST_FIELDS = {'schema', 'requestId', 'repository', 'baseSha', 'sourceSha', 'targetBranch', 'slug', 'intentTemplate'} +MATERIAL_FIELDS = {'schema', 'proposalPath', 'proposalSha256', 'leaseId', 'leaseRevision', 'fencingToken', 'ownerActor', 'ownerSession'} +require = recovery.require +safe = recovery.safe_path +load = recovery.load + + +def raw_digest(path): + return hashlib.sha256(safe(path).read_bytes()).hexdigest() + + +def intent_bytes(intent): + return (json.dumps(intent, indent=2) + '\n').encode() + + +def atomic_write(path, raw): + path = safe(path) + temporary = path.with_name(path.name + '.pending') + with temporary.open('xb') as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +@contextmanager +def allocation_lock(common): + lock = safe(common / 'new-project-ticket-allocation.lock') + lock.mkdir() # Never steal an existing lock, including after interruption. + try: + yield + finally: + lock.rmdir() + + +def context(root): + require(os.name == 'posix' and hasattr(os, 'O_NOFOLLOW'), 'POSIX adapter required') + root = safe(start.git(safe(root), 'rev-parse', '--show-toplevel').strip()) + common = safe(start.git(root, 'rev-parse', '--path-format=absolute', '--git-common-dir').strip()) + primary = safe(start.worktrees(root)[0]['worktree']) + require(start.configured_mode(root) == 'files' and not (primary / 'project.sqlite').exists(), 'File allocation required') + policy = next((p for p in (root / '.governance/ticket-allocation.json', root / 'governance/ticket-allocation.json') if p.exists()), None) + if policy: + require(parse_config(policy)['mode'] == 'local-single-clone', 'Registered allocation requires its own controller') + return root, primary, common + + +def external(root, common, path): + path = safe(path) + require(not path.is_relative_to(common), 'Authority cannot reside in Git metadata') + for entry in start.worktrees(root): + require(not path.is_relative_to(safe(entry['worktree'])), 'Authority must be outside all candidate checkouts') + return path + + +def repository(root): + origin = urlparse(canonical_repository_ref(start.git(root, 'config', '--get', 'remote.origin.url').strip())) + require(origin.hostname == 'github.com' and origin.scheme in {'https', 'git+ssh'}, 'Canonical GitHub identity required') + return origin.path.lstrip('/') + + +def worktree_contract(root): + installed = Path(__file__).resolve().parent + path = next((p for p in (installed / 'worktree_path_check.py', installed.parent / 'subprojects/worktrees/conformance.py') if p.is_file()), None) + require(path is not None, 'Managed Worktrees v5 checker required') + spec = importlib.util.spec_from_file_location('snapshot_worktree_contract', path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def check_request(root, request, workstream): + require(isinstance(request, dict) and set(request) == REQUEST_FIELDS and request['schema'] == REQUEST_SCHEMA, 'Closed proposal request required') + require(isinstance(request['requestId'], str) and re.fullmatch(r'[A-Za-z0-9._/-]{1,160}', request['requestId']), 'Stable request identity required') + require(isinstance(request['slug'], str) and re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', request['slug']), 'Canonical slug required') + require(request['repository'] == repository(root), 'Repository mismatch') + template = request['intentTemplate'] + require(isinstance(template, dict) and 'ticket' not in template and 'delivery' in template, 'Full intent template without a manual identity required') + require(isinstance(template.get('summary'), str) and not any(ord(c) < 32 for c in template['summary']), 'Single-line summary required') + require(template.get('workstream') == workstream and template.get('schema') == 'new-project.intent/v3', 'Workstream or intent mismatch') + delivery = template['delivery'] + require(isinstance(delivery, dict) and 'snapshotMigration' not in delivery, 'Allocator owns the migration contract') + require(delivery.get('acceptedBaseSha') == request['baseSha'] and delivery.get('targetBranch') == request['targetBranch'], 'Delivery subject mismatch') + observed = migration.inventory(root, request['baseSha'], request['sourceSha']) + require(observed['entries'], 'Material source delta required') + migration.git(root, 'merge-base', '--is-ancestor', request['baseSha'], request['sourceSha']) + return observed + + +def proposal_value(root, primary, request, ticket, workstream): + observed = check_request(root, request, workstream) + intent = json.loads(json.dumps(request['intentTemplate'])) + intent['ticket'] = ticket + own = 'project/' + ticket + '/**' + intent['allowedPaths'] = sorted(set(intent['allowedPaths'] + [own])) + intent['delivery']['snapshotMigration'] = {'schema': migration.CONTRACT_SCHEMA, + 'repository': request['repository'], 'baseSha': request['baseSha'], 'sourceSha': request['sourceSha'], + 'sourceTree': observed['sourceTree'], 'inventorySha256': observed['inventorySha256'], + 'authorizationRef': 'authorization:snapshot/' + ticket} + _, error = governance.validate_intent_value(intent, ticket) + require(error is None, 'Complete bounded intent required') + manifest = start.manifest_at(root) + owned = manifest['coordination']['workstreams'][workstream]['ownedPaths'] + require(manifest['delivery']['targetBranches'] == [request['targetBranch']], 'Target mismatch') + require(all(any(governance.pattern_covered_by(p, owner) for owner in owned) + for p in start.material(intent['allowedPaths'])), 'Repair scope exceeds workstream ownership') + require(not any(p.startswith('project/' + ticket + '/') for p in migration.tree(root, request['sourceSha'])), 'Identity already in source') + layout = worktree_contract(root).plan(repository=request['repository'], repository_name=primary.name, + ticket=ticket, slug=request['slug'], primary_checkout=str(primary)) + return {'schema': PROPOSAL_SCHEMA, 'request': request, 'requestDigest': start.digest(request), + 'ticket': ticket, 'intent': intent, 'layout': layout, 'grantsAuthority': False, + 'primaryHead': start.git(primary, 'rev-parse', 'HEAD').strip()} + + +def highest_identity(root, common): + highwater = safe(common / 'new-project-ticket-high-water') + value = highwater.read_text().strip() if highwater.exists() else '0' + require(re.fullmatch(r'[0-9]+', value), 'Invalid high-water reservation') + numbers = [int(value)] + for entry in start.worktrees(root): + for path in safe(entry['worktree']).glob('project/ticket-*'): + match = re.fullmatch(r'ticket-([0-9]{3,})', path.name) + if match: + numbers.append(int(match[1])) + refs = start.git(root, 'for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes').splitlines() + for ref in refs: + match = re.search(r'/ticket(?:/|-)([0-9]{3,})(?:-|$)', ref) + if match: + numbers.append(int(match[1])) + paths = start.git(root, 'ls-tree', '-d', '-r', '--name-only', ref, 'project').splitlines() + numbers.extend(int(m[1]) for p in paths if (m := re.fullmatch(r'project/ticket-([0-9]{3,})', p))) + return max(numbers) + + +def prepare(root, request_path, workstream): + root, primary, common = context(root) + request = load(safe(request_path)) + check_request(root, request, workstream) + with allocation_lock(common): + # Observation does not authorize an import. No branch, checkout, lease + # or source carrier is created in this reservation-only phase. + observation = start.inspect(root, workstream, request['intentTemplate']['allowedPaths']) + require(observation['targetObservations'].get('refs/remotes/origin/' + request['targetBranch'], observation['targetObservations'].get('refs/heads/' + request['targetBranch'])) == request['baseSha'], 'Stale proposal base') + directory = safe(common / 'new-project-snapshot-reservations') + directory.mkdir(exist_ok=True) + path = directory / (hashlib.sha256(request['requestId'].encode()).hexdigest() + '.json') + if path.exists(): + proposal = load(path) + require(proposal.get('requestDigest') == start.digest(request), 'Proposal idempotency collision') + require(proposal == proposal_value(root, primary, request, proposal['ticket'], workstream), 'Proposal changed; preserve and reconcile') + else: + number = highest_identity(root, common) + 1 + proposal = proposal_value(root, primary, request, f'ticket-{number:03d}', workstream) + # Reserve first. A crash can leave a gap, never a duplicate identity. + atomic_write(common / 'new-project-ticket-high-water', (str(number) + '\n').encode()) + atomic_write(path, (json.dumps(proposal, indent=2) + '\n').encode()) + return {'proposalPath': str(path), 'proposalSha256': raw_digest(path), **proposal} + + +def admit(root, intent, grant): + report = start.inspect(root, intent['workstream'], intent['allowedPaths']) + contract = intent['delivery']['snapshotMigration'] + require(report['targetObservations'].get('refs/remotes/origin/' + intent['delivery']['targetBranch'], report['targetObservations'].get('refs/heads/' + intent['delivery']['targetBranch'])) == contract['baseSha'], 'Protected base moved') + require(report['activeTicketCount'] < report['workstreamLimit'], 'Workstream already occupied') + source_tree = migration.tree(root, contract['sourceSha']) + imports = set(grant['implementationPaths']) + for blocker in report['blockers']: + require(blocker['reason'] in {'pending-delta', 'unassigned-branch-delta'}, 'Live repair scope is contested') + if blocker['path']: + peer = next(e for e in report['worktrees'] if e['path'] == blocker['path']) + require(not start.intersects(report['requestedPaths'], peer['allDirtyPaths']), 'Dirty repair scope is contested') + head = peer['headSha'] + else: + head = start.git(root, 'rev-parse', blocker['branch']).strip() + migration.git(root, 'merge-base', '--is-ancestor', contract['sourceSha'], head) + peer_tree = migration.tree(root, head) + contested = [p for p in start.changes(root, contract['baseSha'], head) + if start.path_ignored(p, tuple(report['requestedPaths']))] + require(all(p in imports and source_tree.get(p) == peer_tree.get(p) for p in contested), 'Peer carries an independent repair') + return report + + +def validate_candidate(root, proposal, authorization, digest, head): + # Run this installed managed checker, never executable code from candidate. + adopted = (root / '.governance/manifest.json').exists() + argv = ['--root', str(root), '--base', proposal['request']['baseSha'], '--head', head, + '--manifest', '.governance/manifest.json' if adopted else 'governance/manifest.hub.json', + '--stack-profiles', '.governance/stack-profiles.json' if adopted else 'governance/stack-profiles.json', + '--work-classification', '.governance/work-classification.dsl.json' if adopted else 'governance/work-classification.dsl.json', + '--expected-repository', proposal['request']['repository'], '--migration-branch', proposal['layout']['branch'], + '--migration-authorization', str(authorization), '--migration-authorization-sha256', digest, + '--actor', 'ci', '--no-cache'] + if adopted: + argv += ['--lock', '.governance/manifest.lock.json'] + with redirect_stdout(io.StringIO()): + result = governance.main(argv) + require(result == 0, 'Candidate governance validation failed; preserve detached checkout') + + +def materialize(root, request_path, store, authorization, authorization_digest, workstream): + root, primary, common = context(root) + request_path, store, authorization = [external(root, common, p) for p in (request_path, store, authorization)] + request = load(request_path) + require(isinstance(request, dict) and set(request) == MATERIAL_FIELDS and request['schema'] == MATERIAL_SCHEMA, 'Closed materialization request required') + for field in ('leaseRevision', 'fencingToken'): + require(type(request[field]) is int and request[field] > 0, 'Exact positive CAS required') + path = safe(request['proposalPath']) + require(path.parent == common / 'new-project-snapshot-reservations', 'Unknown reservation path') + require(migration.matches(migration.DIGEST, request['proposalSha256']) and raw_digest(path) == request['proposalSha256'], 'Proposal digest mismatch') + proposal = load(path) + with allocation_lock(common), recovery.controller_lock(store): + require(proposal == proposal_value(root, primary, proposal['request'], proposal['ticket'], workstream), 'Reserved subject changed') + intent, layout = proposal['intent'], proposal['layout'] + branch = 'refs/heads/' + layout['branch'] + grant = migration.load_authorization(root, authorization, authorization_digest) + contract = intent['delivery']['snapshotMigration'] + expected = {'grantId': contract['authorizationRef'], 'repository': proposal['request']['repository'], + 'ticket': intent['ticket'], 'branch': layout['branch'], 'targetBranch': intent['delivery']['targetBranch'], + 'baseSha': contract['baseSha'], 'contractSha256': migration.digest(contract), 'intentSha256': migration.digest(intent)} + require(all(grant[k] == v for k, v in expected.items()), 'Grant subject mismatch') + lease = recovery.validate_lease(store, request, intent, expected['repository'], branch, Path(layout['worktreePath']).name) + require(lease.get('planHash') == hashlib.sha256(intent_bytes(intent)).hexdigest(), 'Controller plan differs') + before_refs = start.git(root, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes') + before = admit(root, intent, grant) + require(start.git(root, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes') == before_refs, 'Refs changed during admission') + checker = worktree_contract(root) + require(checker.feature_probe(from_worktree=str(primary))['supported'], 'Relative worktrees unsupported') + require(not checker.validate(layout) and not checker.validate_filesystem(layout), 'Unsafe layout') + candidate = safe(layout['worktreePath']) + lease_path = safe(layout['leasePath']) + require(not candidate.exists() and not lease_path.exists() and not start.git(root, 'show-ref', '--verify', branch, optional=True), 'Candidate already exists; preserve and reconcile') + # Build an unpublished object with an isolated temporary index. This is + # the managed import transport, not an ordinary author commit. The full + # trusted gate runs before creating a branch ref; no hooks are disabled. + index = common / ('snapshot-index-' + intent['ticket']) + require(not index.exists(), 'Interrupted candidate index exists') + environment = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')} + environment['GIT_INDEX_FILE'] = str(index) + def git(*args, data=None): + return subprocess.check_output(['git', '--no-replace-objects', '-C', str(root), *args], input=data, env=environment, stderr=subprocess.PIPE).decode().strip() + try: + git('read-tree', contract['sourceSha']) + prefix = 'project/' + intent['ticket'] + '/' + readme = f"# {intent['ticket']}: {intent['summary']}\n\n- **Status**: IN_PROGRESS\n- **Workflow state**: EDIT\n\n## Acceptance criteria\n\n" + readme += ''.join(f"- [ ] {v['criterion']}: Validate accepted intent.\n" for v in intent['delivery']['validation']) + for name, raw in (('README.md', readme.encode()), ('intent.json', intent_bytes(intent))): + oid = git('hash-object', '-w', '--stdin', data=raw) + git('update-index', '--add', '--cacheinfo', '100644', oid, prefix + name) + tree = git('write-tree') + head = git('commit-tree', tree, '-p', contract['baseSha'], '-p', contract['sourceSha'], '-m', intent['ticket'] + ': preserve approved snapshot') + finally: + if index.exists(): + index.unlink() # Only this exact private scratch index. + migration.git(root, 'worktree', 'add', '--relative-paths', '--detach', str(candidate), head) + require(not Path((Path(start.git(candidate, 'rev-parse', '--absolute-git-dir').strip()) / 'gitdir').read_text().strip()).is_absolute(), 'Relative backlink required') + migration.prove(candidate, intent, base=contract['baseSha'], head=head, repository=expected['repository'], branch=layout['branch'], authorization_path=authorization, authorization_sha256=authorization_digest) + validate_candidate(candidate, proposal, authorization, authorization_digest, head) + # Compare all original checkouts and refs immediately before the one ref + # effect. A concurrent changed source/dirty scope never grants takeover. + require(start.git(root, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads', 'refs/remotes') == before_refs, 'Refs changed before publication') + for entry in before['worktrees']: + require(start.git(Path(entry['path']), 'rev-parse', 'HEAD').strip() == entry['headSha'] and start.dirty_observation(Path(entry['path']))[1] == entry['dirtyDigest'], 'Peer changed before publication') + migration.load_authorization(candidate, authorization, authorization_digest) + lease = recovery.validate_lease(store, request, intent, expected['repository'], branch, candidate.name) + require(lease.get('planHash') == hashlib.sha256(intent_bytes(intent)).hexdigest(), 'Controller plan changed before publication') + require(raw_digest(path) == request['proposalSha256'], 'Proposal changed before publication') + for ref, observed in before['targetObservations'].items(): + require(start.git(root, 'rev-parse', ref).strip() == observed, 'Target changed before publication') + lease_path.parent.mkdir(parents=True, exist_ok=True) + atomic_write(lease_path, (json.dumps(lease, indent=2) + '\n').encode()) + migration.git(root, 'update-ref', branch, head, '0' * 40) + migration.git(candidate, 'symbolic-ref', 'HEAD', branch) + return {'schema': 'new-project.snapshot-materialization-result/v1', 'ticket': intent['ticket'], + 'headSha': head, 'worktree': str(candidate), 'branch': layout['branch'], + 'leaseId': lease['leaseId'], 'leaseRevision': lease['leaseRevision'], 'fencingToken': lease['fencingToken'], + 'grantsMergeAuthority': False, 'proposalSha256': request['proposalSha256']} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, required=True) + parser.add_argument('--workstream', required=True) + command = parser.add_mutually_exclusive_group(required=True) + command.add_argument('--prepare', type=Path) + command.add_argument('--materialize', type=Path) + parser.add_argument('--lease-store', type=Path) + parser.add_argument('--authorization', type=Path) + parser.add_argument('--authorization-sha256') + args = parser.parse_args(argv) + try: + if args.prepare: + require(not any((args.lease_store, args.authorization, args.authorization_sha256)), 'Proposal has no authority inputs') + result = prepare(args.root, args.prepare, args.workstream) + else: + require(all((args.lease_store, args.authorization, args.authorization_sha256)), 'Protected materialization inputs required') + result = materialize(args.root, args.materialize, args.lease_store, args.authorization, args.authorization_sha256, args.workstream) + print(json.dumps(result)) + return 0 + except (OSError, ValueError, KeyError, TypeError, ImportError, subprocess.CalledProcessError): + print('GOV-TICKET-ALLOCATION-003: snapshot allocation rejected; preserve reservations and candidate state, reobserve subject and controller ownership.', file=sys.stderr) + return 5 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.governance/snapshot_migration.py b/.governance/snapshot_migration.py new file mode 100755 index 0000000..f9d3c65 --- /dev/null +++ b/.governance/snapshot_migration.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Read-only snapshot migration proofs. Protected input, never author self-approval.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess + +CONTRACT_SCHEMA = 'new-project.snapshot-migration/v1' +AUTHORIZATION_SCHEMA = 'new-project.snapshot-migration-authorization/v1' +CONTRACT_FIELDS = {'schema', 'repository', 'baseSha', 'sourceSha', 'sourceTree', 'inventorySha256', 'authorizationRef'} +AUTHORIZATION_FIELDS = {'schema', 'grantId', 'repository', 'ticket', 'branch', 'targetBranch', 'baseSha', 'contractSha256', 'intentSha256', 'implementationPaths', 'historicalTickets', 'maxUses', 'status'} +SHA = re.compile(r'[0-9a-f]{40}') +DIGEST = re.compile(r'[0-9a-f]{64}') +REPOSITORY = re.compile(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+') +MAX_AUTHORIZATION_BYTES = 2 * 1024 * 1024 + + +class MigrationError(ValueError): + def __init__(self, code, detail): + super().__init__(detail) + self.code = code + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode()).hexdigest() + + +def matches(pattern, value): + return isinstance(value, str) and pattern.fullmatch(value) is not None + + +def safe_path(value): + return (isinstance(value, str) and bool(value) and '\\' not in value + and not any(ord(c) < 32 or ord(c) == 127 for c in value) + and all(p not in {'', '.', '..', '.git'} for p in value.split('/')) + and ':' not in value) + + +def contract_error(value): + if not isinstance(value, dict) or set(value) != CONTRACT_FIELDS: + return 'snapshotMigration must be a closed migration contract' + if value['schema'] != CONTRACT_SCHEMA or not matches(REPOSITORY, value['repository']): + return 'snapshotMigration identity is invalid' + if any(not matches(SHA, value[k]) for k in ('baseSha', 'sourceSha', 'sourceTree')): + return 'snapshotMigration revisions must be full lowercase Git SHAs' + if value['baseSha'] == value['sourceSha'] or not matches(DIGEST, value['inventorySha256']): + return 'snapshotMigration source or inventory is invalid' + if not isinstance(value['authorizationRef'], str) or not re.fullmatch(r'authorization:[A-Za-z0-9._/-]{1,200}', value['authorizationRef']): + return 'snapshotMigration requires an explicit authorization reference' + return None + + +def git(root, *args): + try: + return subprocess.check_output(['git', '--no-replace-objects', '-C', str(root), *args], stderr=subprocess.PIPE) + except (OSError, subprocess.CalledProcessError) as error: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-002', 'Required Git subject or complete history is unavailable') from error + + +def commit(root, value): + if not matches(SHA, value): + raise MigrationError('GOV-SNAPSHOT-MIGRATION-002', 'Expected an immutable commit SHA') + return git(root, 'rev-parse', '--verify', value + '^{commit}').decode().strip() + + +def tree(root, revision): + result = {} + for raw in git(root, 'ls-tree', '-r', '-z', '--full-tree', revision).split(b'\0'): + if not raw: + continue + try: + metadata, name = raw.split(b'\t', 1) + mode, kind, oid = metadata.decode('ascii').split() + path = name.decode('utf-8') + except ValueError: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-005', 'Unreadable inventory path or metadata') from None + if not safe_path(path) or kind != 'blob' or mode not in {'100644', '100755', '120000'}: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-005', 'Unsupported inventory path or object type') + if path in result: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-005', 'Duplicate inventory path') + result[path] = {'mode': mode, 'oid': oid} + return result + + +def inventory(root, base, source): + commit(root, base) + commit(root, source) + before, after = tree(root, base), tree(root, source) + entries = [{'path': p, 'base': before.get(p), 'source': after.get(p)} + for p in sorted(before.keys() | after.keys()) if before.get(p) != after.get(p)] + return {'baseSha': base, 'sourceSha': source, + 'sourceTree': git(root, 'rev-parse', source + '^{tree}').decode().strip(), + 'entries': entries, 'inventorySha256': digest(entries)} + + +def unique_object(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError('duplicate JSON key') + value[key] = item + return value + + +def load_authorization(root, path, expected_digest): + if path is None or not matches(DIGEST, expected_digest): + raise MigrationError('GOV-SNAPSHOT-MIGRATION-003', 'Independently pinned external authorization is required') + path = Path(path) + if not path.is_absolute() or path.resolve().is_relative_to(Path(root).resolve()): + raise MigrationError('GOV-SNAPSHOT-MIGRATION-003', 'Authorization must be outside the candidate checkout') + try: + if path.resolve() != path or not hasattr(os, 'O_NOFOLLOW'): + raise ValueError('unsafe authorization path') + with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK), 'rb') as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ValueError('not a regular file') + raw = stream.read(MAX_AUTHORIZATION_BYTES + 1) + if len(raw) > MAX_AUTHORIZATION_BYTES or hashlib.sha256(raw).hexdigest() != expected_digest: + raise ValueError('authorization digest mismatch') + value = json.loads(raw, object_pairs_hook=unique_object) + if not isinstance(value, dict) or set(value) != AUTHORIZATION_FIELDS or value['schema'] != AUTHORIZATION_SCHEMA: + raise ValueError('invalid authorization shape') + paths = value['implementationPaths'] + if (not isinstance(paths, list) or not paths or any(not safe_path(p) for p in paths) + or paths != sorted(set(paths)) or type(value['maxUses']) is not int or value['maxUses'] != 1): + raise ValueError('invalid authorization scope') + historical = value['historicalTickets'] + if (not isinstance(historical, list) or any(not isinstance(t, str) or not re.fullmatch(r'ticket-[0-9]{3,}', t) for t in historical) + or historical != sorted(set(historical))): + raise ValueError('invalid historical ticket inventory') + if value['status'] not in {'reserved', 'consumed'}: + raise ValueError('invalid authorization state') + if value['status'] == 'consumed': + raise MigrationError('GOV-SNAPSHOT-MIGRATION-006', 'Migration authorization has already been consumed') + return value + except MigrationError: + raise + except (OSError, ValueError, TypeError, KeyError) as error: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-003', 'External authorization is invalid or its protected pin differs') from error + + +def _entry_stamp(info): + return (info.st_dev, info.st_ino, info.st_mode, info.st_size, + info.st_mtime_ns, info.st_ctime_ns) + + +def workspace_entry(root, path): + """Bind content and Git mode to one descriptor; reject observed mutation.""" + descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY) + try: + parts = path.split('/') + for part in parts[:-1]: + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + try: + opened = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=descriptor) + except FileNotFoundError: + return None + except OSError: + before = os.stat(parts[-1], dir_fd=descriptor, follow_symlinks=False) + if not stat.S_ISLNK(before.st_mode): + return {'unsupported': True} + raw = os.fsencode(os.readlink(parts[-1], dir_fd=descriptor)) + after = os.stat(parts[-1], dir_fd=descriptor, follow_symlinks=False) + if _entry_stamp(before) != _entry_stamp(after): + return {'unsupported': True} + mode = '120000' + else: + with os.fdopen(opened, 'rb') as stream: + before = os.fstat(stream.fileno()) + if not stat.S_ISREG(before.st_mode): + return {'unsupported': True} + raw = stream.read() + after = os.fstat(stream.fileno()) + current = os.stat(parts[-1], dir_fd=descriptor, follow_symlinks=False) + if (_entry_stamp(before) != _entry_stamp(after) + or _entry_stamp(after) != _entry_stamp(current)): + return {'unsupported': True} + mode = '100755' if after.st_mode & stat.S_IXUSR else '100644' + return {'mode': mode, 'oid': hashlib.sha1(b'blob ' + str(len(raw)).encode() + b'\0' + raw).hexdigest()} + except FileNotFoundError: + return None + except OSError: + return {'unsupported': True} + finally: + os.close(descriptor) + + +def prove(root, intent, *, base, head, repository, branch, authorization_path, authorization_sha256): + contract = intent.get('delivery', {}).get('snapshotMigration') + error = contract_error(contract) + if error: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-001', error) + grant = load_authorization(root, authorization_path, authorization_sha256) + expected = {'grantId': contract['authorizationRef'], 'repository': repository, + 'ticket': intent['ticket'], 'branch': branch, + 'targetBranch': intent['delivery']['targetBranch'], 'baseSha': base, + 'contractSha256': digest(contract), 'intentSha256': digest(intent)} + if (not matches(REPOSITORY, repository) or not isinstance(branch, str) or not branch + or repository != contract['repository'] or any(grant[k] != v for k, v in expected.items())): + raise MigrationError('GOV-SNAPSHOT-MIGRATION-003', 'Authorization is not bound to this repository, ticket, branch and intent') + if base != contract['baseSha'] or base != intent['delivery']['acceptedBaseSha']: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-006', 'Fresh protected base differs from the one-use migration base') + commit(root, base) + source = commit(root, contract['sourceSha']) + head_sha = git(root, 'rev-parse', '--verify', head + '^{commit}').decode().strip() + if git(root, 'rev-parse', 'HEAD').decode().strip() != head_sha or git(root, 'rev-parse', '--is-shallow-repository').strip() != b'false': + raise MigrationError('GOV-SNAPSHOT-MIGRATION-002', 'Validation requires the checked-out head and complete source history') + for ancestor, descendant in ((base, source), (source, head_sha)): + git(root, 'merge-base', '--is-ancestor', ancestor, descendant) + observed = inventory(root, base, source) + if observed['sourceTree'] != contract['sourceTree'] or observed['inventorySha256'] != contract['inventorySha256']: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-005', 'Snapshot tree or inventory differs from the authorized subject') + imported = set(grant['implementationPaths']) + if not imported <= {entry['path'] for entry in observed['entries']}: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-005', 'Authorized implementation inventory includes an unrelated path') + source_tree = tree(root, source) + prefix = 'project/' + intent['ticket'] + '/' + if any(p.startswith(prefix) for p in source_tree): + raise MigrationError('GOV-SNAPSHOT-MIGRATION-006', 'Migration must use a new ticket absent from the preserved source') + new_commits = git(root, 'rev-list', '--reverse', base + '..' + head_sha, '^' + source).decode().splitlines() + boundaries = [sha for sha in new_commits if git(root, 'show', '-s', '--format=%P', sha).decode().split() == [base, source]] + if len(boundaries) != 1: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-004', 'Expected one migration commit with exact base and preserved source parents') + boundary = boundaries[0] + imported_tree = tree(root, boundary) + if {p: v for p, v in imported_tree.items() if not p.startswith(prefix)} != source_tree: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-005', 'The migration commit adds or changes files outside its exact snapshot') + try: + recorded_intent = json.loads(git(root, 'show', boundary + ':' + prefix + 'intent.json'), object_pairs_hook=unique_object) + if recorded_intent != intent or prefix + 'README.md' not in imported_tree: + raise ValueError('intent missing or changed') + except (ValueError, TypeError) as error: + raise MigrationError('GOV-SNAPSHOT-MIGRATION-004', 'Approved intent and README must be in the first migration commit') from error + final_tree = tree(root, head_sha) + for ticket in grant['historicalTickets']: + historical_prefix = 'project/' + ticket + '/' + original = {p: v for p, v in source_tree.items() if p.startswith(historical_prefix)} + current = {p: v for p, v in final_tree.items() if p.startswith(historical_prefix)} + if (ticket == intent['ticket'] or historical_prefix + 'intent.json' not in original + or historical_prefix + 'README.md' not in original or current != original + or any(workspace_entry(root, p) != v for p, v in original.items())): + raise MigrationError('GOV-SNAPSHOT-MIGRATION-004', 'Historical ticket projection is not the unchanged authorized source') + repairs = {p for p in source_tree.keys() | final_tree.keys() if source_tree.get(p) != final_tree.get(p)} + dirty = git(root, 'diff', '--no-ext-diff', '--name-only', '-z', head_sha) + untracked = git(root, 'ls-files', '--others', '--exclude-standard', '-z') + repairs.update(p.decode('utf-8') for p in (dirty + untracked).split(b'\0') if p) + unchanged = {p for p in imported if source_tree.get(p) == final_tree.get(p) + and workspace_entry(root, p) == source_tree.get(p)} + repairs.update(imported - unchanged) + return {'sourceSha': source, 'migrationCommit': boundary, + 'repairPaths': sorted(repairs), 'historicalTickets': grant['historicalTickets'], + 'inventorySha256': observed['inventorySha256'], 'importedPaths': sorted(unchanged), + 'authorizedImplementationFiles': len(imported), 'unchangedImportedFiles': len(unchanged), + 'authority': 'VALIDATION_ONLY'} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, required=True) + parser.add_argument('--base', required=True) + parser.add_argument('--source', required=True) + args = parser.parse_args(argv) + try: + result = inventory(args.root, args.base, args.source) + except (MigrationError, UnicodeError) as error: + print(json.dumps({'status': 'failed', 'code': getattr(error, 'code', 'GOV-SNAPSHOT-MIGRATION-005')})) + return 1 + print(json.dumps({'status': 'observed', 'authority': 'NONE', **result}, indent=2, sort_keys=True)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.governance/stack-profiles.json b/.governance/stack-profiles.json new file mode 100644 index 0000000..3d58322 --- /dev/null +++ b/.governance/stack-profiles.json @@ -0,0 +1,14 @@ +{ + "schema": "new-project.stack-profiles/v1", + "profiles": { + "node": { "anyFiles": ["package.json"], "recommended": ["npm ci", "lint", "typecheck", "test", "dependency audit"] }, + "python": { "anyFiles": ["pyproject.toml", "requirements.txt"], "recommended": ["ruff", "mypy", "pytest", "bandit", "pip-audit"] }, + "go": { "anyFiles": ["go.mod"], "recommended": ["gofmt", "go vet", "go test -race", "staticcheck", "govulncheck"] }, + "rust": { "anyFiles": ["Cargo.toml"], "recommended": ["cargo fmt --check", "cargo clippy -- -D warnings", "cargo test", "cargo deny"] }, + "java": { "anyFiles": ["pom.xml", "build.gradle", "build.gradle.kts"], "recommended": ["wrapper verify", "tests", "Checkstyle or SpotBugs", "dependency audit"] }, + "docker": { "anyFiles": ["Dockerfile", "Dockerfile.e2e", "compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml"], "recommended": ["hadolint", "docker compose config", "build", "Trivy", "SBOM"] }, + "frontend": { "anyFiles": ["playwright.config.ts", "playwright.config.js", "cypress.config.ts", "cypress.config.js"], "recommended": ["browser E2E", "accessibility", "pinned browser image"] }, + "terraform": { "anyFiles": ["main.tf", "versions.tf"], "recommended": ["terraform fmt -check", "terraform validate", "tflint", "checkov"] }, + "kubernetes": { "anyFiles": ["Chart.yaml", "kustomization.yaml"], "recommended": ["helm lint", "kubeconform", "OPA/Conftest"] } + } +} diff --git a/.governance/standard-adoption-plan.schema.json b/.governance/standard-adoption-plan.schema.json new file mode 100644 index 0000000..e80d7f8 --- /dev/null +++ b/.governance/standard-adoption-plan.schema.json @@ -0,0 +1,623 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/standard-adoption-plan.v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "readOnly", + "grantsAuthority", + "source", + "target", + "prospectiveManifest", + "ownershipAdditions", + "files", + "allowedPaths", + "delivery", + "digest" + ], + "properties": { + "schema": { + "const": "new-project.standard-adoption-plan/v1" + }, + "readOnly": { + "const": true + }, + "grantsAuthority": { + "const": false + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "revision", + "version", + "packageSha256", + "exporterSha256", + "allocatorSha256" + ], + "properties": { + "repository": { + "const": "wellmanifest/new-project" + }, + "revision": { + "$ref": "#/$defs/sha" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "packageSha256": { + "$ref": "#/$defs/sha256" + }, + "exporterSha256": { + "$ref": "#/$defs/sha256" + }, + "allocatorSha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": [ + "root", + "head", + "branch", + "statusSha256", + "index", + "dirtyFiles", + "files" + ], + "properties": { + "root": { + "type": "string", + "minLength": 1 + }, + "head": { + "$ref": "#/$defs/sha" + }, + "branch": { + "type": "string", + "minLength": 1, + "description": "Observed named primary branch; aliases require exact HEAD, local main and origin/main equality at export and verification." + }, + "statusSha256": { + "$ref": "#/$defs/sha256" + }, + "index": { + "$ref": "#/$defs/fileState" + }, + "dirtyFiles": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/literalPath" + }, + "additionalProperties": { + "$ref": "#/$defs/fileState" + } + }, + "files": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/literalPath" + }, + "additionalProperties": { + "$ref": "#/$defs/fileState" + } + } + } + }, + "prospectiveManifest": { + "type": "object", + "required": [ + "standard", + "coordination", + "delivery" + ] + }, + "ownershipAdditions": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/literalPath" + } + }, + "files": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha256", + "gitMode", + "strategy" + ], + "properties": { + "path": { + "$ref": "#/$defs/literalPath" + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "gitMode": { + "enum": [ + "100644", + "100755" + ] + }, + "strategy": { + "enum": [ + "managed", + "seed", + "extendable", + "generated" + ] + } + } + } + }, + "allowedPaths": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/literalPath" + }, + "minItems": 1 + }, + "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", + "M", + "L" + ] + }, + "estimatedMinutes": { + "type": "integer", + "minimum": 1, + "maximum": 240 + }, + "snapshotMigration": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "repository", + "baseSha", + "sourceSha", + "sourceTree", + "inventorySha256", + "authorizationRef" + ], + "properties": { + "schema": { + "const": "new-project.snapshot-migration/v1" + }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "baseSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "sourceSha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "sourceTree": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "inventorySha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "authorizationRef": { + "type": "string", + "pattern": "^authorization:[A-Za-z0-9._/-]{1,200}$" + } + } + }, + "standardAdoption": { + "type": "object", + "additionalProperties": false, + "required": [ + "sourceRepository", + "fromRevision", + "toRevision" + ], + "properties": { + "sourceRepository": { + "const": "wellmanifest/new-project" + }, + "fromRevision": { + "oneOf": [ + { + "$ref": "#/$defs/sha" + }, + { + "type": "null" + } + ] + }, + "toRevision": { + "$ref": "#/$defs/sha" + }, + "managedTargetTakeovers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "baseDigest" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?![A-Za-z]:[/\\\\])(?!.*(?:^|/)\\.\\.(?:/|$))[^*?\\[]+$" + }, + "baseDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + }, + "managedTargetRestorations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "baseDigest" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?![A-Za-z]:[/\\\\])(?!.*(?:^|/)\\.\\.(?:/|$))[^*?\\[]+$" + }, + "baseDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + }, + "targetOwnedTransitions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "baseDigest", + "headDigest" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?![A-Za-z]:[/\\\\])(?!.*(?:^|/)\\.\\.(?:/|$))[^*?\\[]+$" + }, + "baseDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "headDigest": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } + } + }, + "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": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "component", + "description" + ], + "properties": { + "kind": { + "enum": [ + "component-local-state", + "schema-migration", + "cross-component-migration", + "ownership-transfer", + "unknown" + ] + }, + "component": { + "type": "string", + "minLength": 1 + }, + "description": { + "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 + } + } + } + } + } + }, + "digest": { + "$ref": "#/$defs/sha256" + } + }, + "$defs": { + "glob": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" + }, + "branch": { + "type": "string", + "minLength": 1, + "pattern": "^(?!@?$)(?!HEAD$)(?![-/])(?![\\s\\S]*(?:\\.\\.|//|@\\{|[ ~^:?*\\[\\\\\\x00-\\x1f\\x7f]))(?![\\s\\S]*(?:^|/)\\.)(?![\\s\\S]*(?:^|/)[^/]*\\.lock(?:/|$))(?![\\s\\S]*[/.]$)[\\s\\S]+$" + }, + "sha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "literalPath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)(?:\\.|\\.\\.|\\.git)(?:/|$))(?!.*[\\\\*?\\[\\]\\x00-\\x1f\\x7f])[^/]+(?:/[^/]+)*$" + }, + "fileState": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "required": [ + "sha256", + "mode" + ], + "properties": { + "sha256": { + "$ref": "#/$defs/sha256" + }, + "mode": { + "type": "integer", + "minimum": 0, + "maximum": 4095 + } + } + } + } +} diff --git a/.governance/standard-adoption.json b/.governance/standard-adoption.json new file mode 100644 index 0000000..04c8062 --- /dev/null +++ b/.governance/standard-adoption.json @@ -0,0 +1,13 @@ +{ + "schema": "wellmanifest.standard-adoption/v1", + "mode": "audit", + "profile": "baseline", + "repositoryRole": "unclassified", + "updates": { + "enabled": true, + "trigger": "pre-commit", + "action": "prepare-and-abort", + "executor": "goal" + }, + "adoptions": [] +} diff --git a/.governance/standard-adoption.schema.json b/.governance/standard-adoption.schema.json new file mode 100644 index 0000000..1582edc --- /dev/null +++ b/.governance/standard-adoption.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.dev/schemas/standard-adoption/v1", + "title": "Wellmanifest standard pack adoption", + "type": "object", + "additionalProperties": false, + "required": ["schema", "mode", "profile", "repositoryRole", "adoptions"], + "properties": { + "schema": {"const": "wellmanifest.standard-adoption/v1"}, + "mode": {"enum": ["audit", "enforce"]}, + "profile": {"type": "string", "minLength": 1}, + "repositoryRole": {"type": "string", "minLength": 1}, + "updates": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "trigger", "action", "executor"], + "properties": { + "enabled": {"type": "boolean"}, + "trigger": {"const": "pre-commit"}, + "action": {"const": "prepare-and-abort"}, + "executor": {"enum": ["goal", "koru-goal"]} + } + }, + "adoptions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "revision", "model", "level", "artifacts", "evidence"], + "properties": { + "id": {"type": "string", "pattern": "^wellmanifest/[a-z0-9][a-z0-9-]*$"}, + "version": {"type": "string", "minLength": 1}, + "revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "model": {"enum": ["reference-only", "local-conformance", "protected-conformance", "runtime-conformance"]}, + "level": {"enum": ["S0", "S1", "S2", "S3", "S4", "S5"]}, + "artifacts": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["target", "sha256"], "properties": {"target": {"type": "string", "minLength": 1}, "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}}}}, + "evidence": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["level", "uri", "sha256"], "properties": {"level": {"enum": ["S0", "S1", "S2", "S3", "S4", "S5"]}, "uri": {"type": "string", "minLength": 1}, "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}}}} + } + } + } + } +} diff --git a/.governance/standard-packs.json b/.governance/standard-packs.json new file mode 100644 index 0000000..59dea3f --- /dev/null +++ b/.governance/standard-packs.json @@ -0,0 +1,311 @@ +{ + "schema": "wellmanifest.standard-pack-routing/v1", + "owner": "wellmanifest/new-project", + "purpose": "composition-only", + "aliases": { + "wellmanifest/git": "wellmanifest/git-lifecycle" + }, + "rules": { + "oneSemanticOwner": true, + "aliasesMustNotCreateRepositories": true, + "adoptionProjectionRequiresImmutableRevision": true, + "adoptionProjectionRequiresDigest": true, + "runtimeBindingsRemainWithAdopter": true + }, + "executionModels": { + "reference-only": { + "maximumLevel": "S0", + "authorizesEffects": false + }, + "local-conformance": { + "maximumLevel": "S2", + "authorizesEffects": false + }, + "protected-conformance": { + "maximumLevel": "S4", + "authorizesEffects": false + }, + "runtime-conformance": { + "maximumLevel": "S5", + "authorizesEffects": true + } + }, + "levels": { + "S0": "Versioned normative contract with closed schemas and negative examples.", + "S1": "Dependency-free deterministic conformance command with stable finding codes.", + "S2": "Immutable source revision and SHA-256 digests for every managed projection.", + "S3": "The conformance command runs as a stable required CI check on pull requests.", + "S4": "Hosted branch protection or rulesets require the exact CI check before merge.", + "S5": "The effectful runtime validates live bindings and emits a digest-bound receipt." + }, + "profiles": { + "baseline": { + "extends": [], + "requirements": [ + { + "id": "wellmanifest/new-project", + "minimumLevel": "S4" + }, + { + "id": "wellmanifest/git-lifecycle", + "minimumLevel": "S4" + }, + { + "id": "wellmanifest/worktrees", + "minimumLevel": "S3" + }, + { + "id": "wellmanifest/merge", + "minimumLevel": "S4" + }, + { + "id": "wellmanifest/validation-attestation", + "minimumLevel": "S4" + }, + { + "id": "wellmanifest/ticket-lifecycle", + "minimumLevel": "S3" + }, + { + "id": "wellmanifest/logs", + "minimumLevel": "S3" + }, + { + "id": "wellmanifest/docs", + "minimumLevel": "S3" + } + ] + }, + "domain-pack": { + "extends": [ + "baseline" + ], + "requirements": [ + { + "id": "wellmanifest/dsl", + "minimumLevel": "S4" + } + ] + }, + "runtime-service": { + "extends": [ + "baseline" + ], + "requirements": [ + { + "id": "wellmanifest/poa", + "minimumLevel": "S5" + }, + { + "id": "wellmanifest/authority-lifecycle", + "minimumLevel": "S5" + }, + { + "id": "wellmanifest/logs", + "minimumLevel": "S5" + } + ] + }, + "agent-executor": { + "extends": [ + "runtime-service" + ], + "requirements": [ + { + "id": "wellmanifest/repair-lifecycle", + "minimumLevel": "S5" + }, + { + "id": "wellmanifest/validation-attestation", + "minimumLevel": "S5" + }, + { + "id": "wellmanifest/session-recovery", + "minimumLevel": "S3" + } + ] + }, + "deployment": { + "extends": [ + "runtime-service" + ], + "requirements": [ + { + "id": "wellmanifest/deployment", + "minimumLevel": "S5" + }, + { + "id": "wellmanifest/merge", + "minimumLevel": "S5" + } + ] + }, + "embedded-fleet": { + "extends": [ + "deployment" + ], + "requirements": [ + { + "id": "wellmanifest/semver", + "minimumLevel": "S3" + } + ] + } + }, + "duplicationPolicy": { + "independentNormativeSourcesForOneConcern": "error", + "managedProjectionWithMatchingRevisionAndDigest": "allowed", + "managedProjectionWithDigestDrift": "error", + "historicalWorktree": "exclude-from-authority-scan-and-audit-via-wellmanifest/worktrees", + "compatibilityAlias": "allowed-only-when-it-resolves-to-one-canonical-pack" + }, + "rollout": { + "defaultMode": "audit", + "targetMode": "enforce", + "transition": "Attach catalog and checker, record immutable evidence, enable the exact required check, then switch the adoption record to enforce." + }, + "packs": [ + { + "id": "wellmanifest/new-project", + "owns": [ + "repository bootstrap", + "pack composition", + "immutable adoption projection" + ] + }, + { + "id": "wellmanifest/git-lifecycle", + "owns": [ + "repository identity", + "branch and ref lifecycle", + "pull-request lifecycle", + "remote hygiene" + ] + }, + { + "id": "wellmanifest/worktrees", + "owns": [ + "worktree placement", + "worktree naming", + "lease path identity", + "local checkout audit handoff" + ] + }, + { + "id": "wellmanifest/merge", + "owns": [ + "divergent-work disposition", + "merge decision contract" + ], + "excludes": [ + "merge execution", + "deletion" + ] + }, + { + "id": "wellmanifest/validation-attestation", + "owns": [ + "trusted exact-head evidence", + "attestation validation" + ] + }, + { + "id": "wellmanifest/ticket-lifecycle", + "owns": [ + "ticket state semantics", + "ticket transition semantics" + ] + }, + { + "id": "wellmanifest/authority-lifecycle", + "owns": [ + "authority lease semantics", + "fencing and expiry semantics" + ] + }, + { + "id": "wellmanifest/llm", + "owns": [ + "LLM policy boundary" + ], + "excludes": [ + "runtime provider credentials", + "runtime model routing" + ] + }, + { + "id": "wellmanifest/logs", + "owns": [ + "structured log contract", + "diagnostic event vocabulary" + ] + }, + { + "id": "wellmanifest/docs", + "owns": [ + "documentation ownership", + "canonical document paths", + "documentation gate" + ] + }, + { + "id": "wellmanifest/poa", + "owns": [ + "plan of action contract" + ] + }, + { + "id": "wellmanifest/dsl", + "owns": [ + "DSL interoperability contract" + ] + }, + { + "id": "wellmanifest/code-dsl", + "owns": [ + "code-level semantic query contract", + "AST diagnostics model" + ] + }, + { + "id": "wellmanifest/nl-dsl-llm", + "owns": [ + "tripartite natural-language, canonical DSL, and adaptive LLM contract", + "universal MCP/CLI parity" + ] + }, + { + "id": "wellmanifest/repair-lifecycle", + "owns": [ + "repair and remediation lifecycle" + ] + }, + { + "id": "wellmanifest/deployment", + "owns": [ + "deployment contract", + "deployment verification" + ] + }, + { + "id": "wellmanifest/session-recovery", + "owns": [ + "LLM session recovery", + "conversation storage resilience", + "host resumption protocol", + "session crash isolation", + "state rehydration" + ] + }, + { + "id": "wellmanifest/semver", + "owns": [ + "embedded semantic versioning", + "heterogeneous fleet release manifest", + "dual-build CI/CD", + "hardware safety contracts", + "A/B dual-bank OTA lifecycle" + ] + } + ] +} diff --git a/.governance/standard-propagation-request.schema.json b/.governance/standard-propagation-request.schema.json new file mode 100644 index 0000000..0c11e7d --- /dev/null +++ b/.governance/standard-propagation-request.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/standard-propagation-request.schema.json", + "title": "Advisory immutable standard propagation request", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "readOnly", + "executionAuthorized", + "targets", + "source" + ], + "properties": { + "schema": { + "const": "new-project.standard-propagation-request/v1" + }, + "readOnly": { + "const": true + }, + "executionAuthorized": { + "const": false + }, + "targets": { + "type": "array", + "maxItems": 0 + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "version", + "tag", + "revision", + "publicationStatus", + "packageManifestSha256", + "adoptionToolSha256" + ], + "properties": { + "repository": { + "const": "wellmanifest/new-project" + }, + "version": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + }, + "tag": { + "type": "string", + "pattern": "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + }, + "revision": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "publicationStatus": { + "const": "published" + }, + "packageManifestSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "adoptionToolSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } +} diff --git a/.governance/standard_pack_check.py b/.governance/standard_pack_check.py new file mode 100755 index 0000000..ee10b3d --- /dev/null +++ b/.governance/standard_pack_check.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Validate Wellmanifest pack ownership, evidence and managed projections.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path, PureWindowsPath +from typing import Any + +LEVELS = {f"S{index}": index for index in range(6)} +SHA40 = re.compile(r"^[0-9a-f]{40}$") +SHA64 = re.compile(r"^[0-9a-f]{64}$") +DIAGNOSTIC_CODE = "GOV-STANDARD-PACK-001" + + +def load_json(path: Path) -> Any: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def sha256(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + value.update(chunk) + return value.hexdigest() + + +def finding(code: str, message: str, path: str = "") -> dict[str, str]: + return { + "code": code, + "diagnostic": DIAGNOSTIC_CODE, + "message": message, + "path": path, + } + + +def catalog_ownership_findings(packs, pack_ids, concerns, findings) -> None: + for pack in packs: + owns = pack.get("owns") if isinstance(pack, dict) else None + if (not isinstance(pack, dict) or not isinstance(pack.get("id"), str) + or not pack["id"] or not isinstance(owns, list)): + findings.append(finding("STD-PACK-CATALOG", "each pack needs an id and ownership list")) + continue + pack_id = pack["id"] + if pack_id in pack_ids: + findings.append(finding("STD-PACK-DUPLICATE-ID", f"duplicate pack id: {pack_id}")) + pack_ids.add(pack_id) + for concern in owns: + if not isinstance(concern, str) or not concern: + findings.append(finding("STD-PACK-CATALOG", f"invalid ownership claim in {pack_id}")) + continue + elif concern in concerns: + findings.append(finding("STD-PACK-DUPLICATE-OWNER", f"duplicate normative owner: {concern}")) + concerns.add(concern) + + +def catalog_profile_findings(profiles, pack_ids, findings) -> None: + for name, profile in profiles.items(): + if not isinstance(profile, dict): + findings.append(finding("STD-PACK-PROFILE", f"profile {name} is not an object")) + continue + parents = profile.get("extends", []) + if not isinstance(parents, list): + findings.append(finding("STD-PACK-PROFILE", f"profile {name} extends must be an array")) + parents = [] + for parent in parents: + if not isinstance(parent, str) or parent not in profiles or parent == name: + findings.append(finding("STD-PACK-PROFILE", f"profile {name} has invalid parent {parent}")) + requirements = profile.get("requirements", []) + if not isinstance(requirements, list): + findings.append(finding("STD-PACK-PROFILE", f"profile {name} requirements must be an array")) + requirements = [] + for requirement in requirements: + if (not isinstance(requirement, dict) + or not isinstance(requirement.get("id"), str) + or requirement["id"] not in pack_ids + or not isinstance(requirement.get("minimumLevel"), str) + or requirement["minimumLevel"] not in LEVELS): + findings.append(finding("STD-PACK-PROFILE", f"profile {name} has invalid requirement")) + + +def catalog_findings(catalog: Any) -> list[dict[str, str]]: + findings: list[dict[str, str]] = [] + if not isinstance(catalog, dict) or catalog.get("schema") != "wellmanifest.standard-pack-routing/v1": + return [finding("STD-PACK-CATALOG", "unsupported standard pack catalog schema")] + packs = catalog.get("packs") + profiles = catalog.get("profiles") + models = catalog.get("executionModels") + if not isinstance(packs, list) or not isinstance(profiles, dict) or not isinstance(models, dict): + return [finding("STD-PACK-CATALOG", "catalog must define packs, profiles and executionModels")] + pack_ids: set[str] = set() + concerns: set[str] = set() + catalog_ownership_findings(packs, pack_ids, concerns, findings) + aliases = catalog.get("aliases", {}) + if not isinstance(aliases, dict): + findings.append(finding("STD-PACK-ALIAS", "aliases must be an object")) + aliases = {} + for alias, target in aliases.items(): + if (not isinstance(alias, str) or not alias or alias in pack_ids + or not isinstance(target, str) or target not in pack_ids): + findings.append(finding("STD-PACK-ALIAS", f"invalid compatibility alias: {alias} -> {target}")) + catalog_profile_findings(profiles, pack_ids, findings) + for name, model in models.items(): + if not isinstance(name, str) or not name.strip(): + findings.append(finding("STD-PACK-CATALOG", "execution model name must be nonempty")) + if not isinstance(model, dict): + findings.append(finding("STD-PACK-CATALOG", f"execution model {name} must be an object")) + continue + level = model.get("maximumLevel") + if not isinstance(level, str) or level not in LEVELS: + findings.append(finding("STD-PACK-CATALOG", f"execution model {name} has invalid maximumLevel")) + if not isinstance(model.get("authorizesEffects"), bool): + findings.append(finding("STD-PACK-CATALOG", f"execution model {name} authorizesEffects must be boolean")) + return findings + + +def profile_requirements(catalog: dict[str, Any], name: str) -> dict[str, str]: + profiles = catalog["profiles"] + result: dict[str, str] = {} + visiting: set[str] = set() + + def visit(profile_name: str) -> None: + if profile_name in visiting: + raise ValueError(f"profile inheritance cycle at {profile_name}") + profile = profiles.get(profile_name) + if not isinstance(profile, dict): + raise ValueError(f"unknown profile {profile_name}") + visiting.add(profile_name) + for parent in profile.get("extends", []): + visit(parent) + for requirement in profile.get("requirements", []): + pack_id = requirement["id"] + level = requirement["minimumLevel"] + if pack_id not in result or LEVELS[level] > LEVELS[result[pack_id]]: + result[pack_id] = level + visiting.remove(profile_name) + + visit(name) + return result + + +CI_EVIDENCE = (".github/workflows/", ".onedev-buildspec", ".governance/required-checks.json") +EXTERNAL_EVIDENCE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:") # RFC 3986 scheme (2+ chars, not a drive letter) + + +def evidence_proves(root, item) -> bool: + """External proof is taken as declared; repository evidence must exist and match its digest.""" + uri = item["uri"] + if EXTERNAL_EVIDENCE.match(uri): + return True + if PureWindowsPath(uri).drive or PureWindowsPath(uri).root or ".." in Path(uri).parts: + return False + try: + path = (root / uri).resolve() + path.relative_to(root.resolve()) + return path.is_file() and sha256(path) == item["sha256"] + except (OSError, ValueError, RuntimeError): + return False + + +def adoption_artifact_findings(root, pack_id, record, level, findings) -> None: + artifacts = record.get("artifacts") + if LEVELS[level] >= LEVELS["S2"] and not isinstance(artifacts, list): + findings.append(finding("STD-ADOPTION-ARTIFACT", f"{pack_id} needs managed artifact digests")) + return + for artifact in artifacts or []: + if not isinstance(artifact, dict): + findings.append(finding("STD-ADOPTION-ARTIFACT", f"invalid artifact for {pack_id}")) + continue + target = artifact.get("target") + expected_digest = artifact.get("sha256") + if (not isinstance(target, str) or not target + or PureWindowsPath(target).drive or PureWindowsPath(target).root + or ".." in PureWindowsPath(target).parts or ".." in Path(target).parts): + findings.append(finding("STD-ADOPTION-ARTIFACT", f"unsafe target for {pack_id}")) + continue + try: + target_path = (root / target).resolve() + target_path.relative_to(root.resolve()) + except (OSError, ValueError, RuntimeError): + findings.append(finding("STD-ADOPTION-ARTIFACT", f"unsafe target for {pack_id}", target)) + continue + try: + if not target_path.is_file(): + findings.append(finding("STD-ADOPTION-MISSING", f"managed projection is missing for {pack_id}", target)) + elif SHA64.fullmatch(str(expected_digest or "")) is None or sha256(target_path) != expected_digest: + findings.append(finding("STD-ADOPTION-DRIFT", f"managed projection drift for {pack_id}", target)) + except OSError: + findings.append(finding("STD-ADOPTION-ARTIFACT", f"cannot read managed projection for {pack_id}", target)) + +def adoption_record_findings(root, pack_id, record, models, findings) -> None: + level = record.get("level") + model = record.get("model") + if level not in LEVELS or model not in models: + findings.append(finding("STD-ADOPTION-RECORD", f"invalid model or level for {pack_id}")) + return + if LEVELS[level] > LEVELS.get(models[model].get("maximumLevel"), -1): + findings.append(finding("STD-ADOPTION-MODEL", f"{model} cannot claim {level} for {pack_id}")) + revision = record.get("revision") + if not isinstance(revision, str) or SHA40.fullmatch(revision) is None: + findings.append(finding("STD-ADOPTION-REVISION", f"{pack_id} needs an immutable 40-character revision")) + evidence = record.get("evidence") + by_level = {} + for item in evidence or []: + if (isinstance(item, dict) and isinstance(item.get("uri"), str) + and SHA64.fullmatch(str(item.get("sha256", ""))) and evidence_proves(root, item)): + by_level.setdefault(item.get("level"), item) + for index in range(LEVELS[level] + 1): + expected = f"S{index}" + if expected not in by_level: + findings.append(finding("STD-ADOPTION-EVIDENCE", f"{pack_id} lacks valid {expected} evidence")) + s3 = by_level.get("S3") + if s3 and not (EXTERNAL_EVIDENCE.match(s3["uri"]) or s3["uri"].startswith(CI_EVIDENCE)): + findings.append(finding("STD-ADOPTION-EVIDENCE", f"{pack_id} S3 evidence must be a CI definition or external proof", s3["uri"])) + for upper in ("S4", "S5"): + item = by_level.get(upper) + if item and not EXTERNAL_EVIDENCE.match(item["uri"]): + findings.append(finding("STD-ADOPTION-EVIDENCE", f"{pack_id} {upper} evidence must be external proof (hosted ruleset or runtime receipt)", item["uri"])) + lower = {by_level[f"S{i}"]["uri"] for i in range(3) if f"S{i}" in by_level} + seen = set() + for upper in ("S3", "S4", "S5"): + item = by_level.get(upper) + if item is None: + continue + if item["uri"] in lower or item["uri"] in seen: + findings.append(finding("STD-ADOPTION-EVIDENCE", f"{pack_id} {upper} evidence reuses lower-level proof", item["uri"])) + seen.add(item["uri"]) + adoption_artifact_findings(root, pack_id, record, level, findings) + + +def adoption_record_index(records, findings): + by_id: dict[str, dict[str, Any]] = {} + for record in records: + if not isinstance(record, dict) or not isinstance(record.get("id"), str): + findings.append(finding("STD-ADOPTION-RECORD", "adoption entry must have an id")) + continue + pack_id = record["id"] + if pack_id in by_id: + findings.append(finding("STD-ADOPTION-DUPLICATE", f"duplicate adoption: {pack_id}")) + by_id[pack_id] = record + return by_id + + +def adoption_findings(root: Path, catalog: dict[str, Any], adoption: Any) -> list[dict[str, str]]: + findings: list[dict[str, str]] = [] + if not isinstance(adoption, dict) or adoption.get("schema") != "wellmanifest.standard-adoption/v1": + return [finding("STD-ADOPTION-SCHEMA", "unsupported standard adoption schema")] + if adoption.get("mode") not in {"audit", "enforce"}: + findings.append(finding("STD-ADOPTION-MODE", "mode must be audit or enforce")) + try: + required = profile_requirements(catalog, adoption.get("profile", "")) + except ValueError as exc: + return [finding("STD-ADOPTION-PROFILE", str(exc))] + records = adoption.get("adoptions") + if not isinstance(records, list): + return [finding("STD-ADOPTION-SCHEMA", "adoptions must be an array")] + by_id = adoption_record_index(records, findings) + models = catalog["executionModels"] + known_packs = {item["id"] for item in catalog["packs"]} + for pack_id, record in by_id.items(): + if pack_id not in known_packs: + findings.append(finding("STD-ADOPTION-UNKNOWN", f"unknown pack: {pack_id}")) + continue + adoption_record_findings(root, pack_id, record, models, findings) + for pack_id, minimum in required.items(): + record = by_id.get(pack_id) + if record is None: + findings.append(finding("STD-PACK-MISSING", f"profile requires {pack_id} at {minimum}")) + elif record.get("level") not in LEVELS or LEVELS[record["level"]] < LEVELS[minimum]: + findings.append(finding("STD-PACK-LEVEL", f"profile requires {pack_id} at {minimum}")) + return findings + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".") + parser.add_argument("--catalog", default=".governance/standard-packs.json") + parser.add_argument("--adoption", default=".governance/standard-adoption.json") + parser.add_argument("--format", choices=("text", "json"), default="text") + parser.add_argument( + "--strict", + action="store_true", + help="return non-zero for findings even when the adoption record is in audit mode", + ) + args = parser.parse_args(argv) + root = Path(args.root).resolve() + catalog_path = root / args.catalog + adoption_path = root / args.adoption + structural: list[dict[str, str]] = [] + if not catalog_path.is_file(): + structural.append(finding("STD-PACK-CATALOG", "standard pack catalog is missing", args.catalog)) + catalog: dict[str, Any] = {} + else: + catalog = load_json(catalog_path) + structural.extend(catalog_findings(catalog)) + if not adoption_path.is_file(): + structural.append(finding("STD-ADOPTION-MISSING", "standard adoption record is missing", args.adoption)) + adoption: dict[str, Any] = {"mode": "enforce"} + else: + adoption = load_json(adoption_path) + findings = structural or adoption_findings(root, catalog, adoption) + result = { + "schema": "wellmanifest.standard-adoption-report/v1", + "mode": adoption.get("mode", "enforce"), + "profile": adoption.get("profile"), + "strict": args.strict, + "ok": not findings, + "findings": findings, + } + if args.format == "json": + print(json.dumps(result, indent=2, sort_keys=True)) + else: + print(f"standard-pack-check: mode={result['mode']} profile={result['profile']} findings={len(findings)}") + for item in findings: + suffix = f" ({item['path']})" if item.get("path") else "" + print(f"{item['code']}: {item['message']}{suffix}") + if structural: + return 2 + return 1 if findings and (args.strict or adoption.get("mode") == "enforce") else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/templates/conftest-worktree-bootstrap.py b/.governance/templates/conftest-worktree-bootstrap.py new file mode 100644 index 0000000..1315305 --- /dev/null +++ b/.governance/templates/conftest-worktree-bootstrap.py @@ -0,0 +1,52 @@ +"""Worktree test bootstrap contract (copy into tests/conftest.py). + +Provenance: adapted from autogrammar/hillm after commits 305361a and b8a9f8a. +Coding-agent worktrees often lack .venv and dev-installed CLIs. This +session-scoped autouse fixture fail-closes before tests instead of mid-suite +import errors. + +Replace ROOT, REQUIRED_CLIS, and the install command for your repository. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +VENV_BIN = ROOT / ".venv" / "bin" +REQUIRED_CLIS = ("your-cli",) # every console script tests invoke + + +def _dev_install_complete() -> bool: + return all((VENV_BIN / cli).is_file() for cli in REQUIRED_CLIS) + + +def _ensure_dev_install() -> None: + if _dev_install_complete(): + return + if not (ROOT / ".venv").is_dir(): + subprocess.run( + [sys.executable, "-m", "venv", str(ROOT / ".venv")], + cwd=ROOT, + check=True, + ) + subprocess.run( + ["bash", "packages/install-dev.sh"], # or project-specific installer + cwd=ROOT, + check=True, + ) + if not _dev_install_complete(): + missing = [cli for cli in REQUIRED_CLIS if not (VENV_BIN / cli).is_file()] + raise RuntimeError(f"dev install incomplete; missing CLIs: {missing}") + + +@pytest.fixture(scope="session", autouse=True) +def _bootstrap_project_env() -> None: + _ensure_dev_install() + path = os.environ.get("PATH", os.defpath) + os.environ["PATH"] = f"{VENV_BIN}{os.pathsep}{path}" diff --git a/.governance/terminal-receipt-registry.schema.json b/.governance/terminal-receipt-registry.schema.json new file mode 100644 index 0000000..348c18f --- /dev/null +++ b/.governance/terminal-receipt-registry.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.dev/schemas/new-project/terminal-receipt-registry.v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": ["schema", "repositoryRef", "receipts"], + "properties": { + "schema": {"const": "new-project.terminal-receipt-registry/v1"}, + "repositoryRef": {"type": "string", "minLength": 1}, + "receipts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["receiptRef", "ticket", "outcome", "headSha", "terminalSha", "targetBranch", "occurredAt"], + "properties": { + "receiptRef": {"type": "string", "pattern": "^receipt:\\S+$"}, + "ticket": {"type": "string", "pattern": "^ticket-[0-9]{3,}$"}, + "outcome": {"type": "string", "minLength": 1}, + "headSha": {"type": "string", "pattern": "^[a-f0-9]{40}$"}, + "terminalSha": {"type": "string", "pattern": "^[a-f0-9]{40}$"}, + "targetBranch": {"type": "string", "pattern": "^[A-Za-z0-9._/-]+$"}, + "occurredAt": {"type": "string", "format": "date-time"} + } + } + } + } +} diff --git a/.governance/terminal_lease_release.py b/.governance/terminal_lease_release.py new file mode 100755 index 0000000..44c1df0 --- /dev/null +++ b/.governance/terminal_lease_release.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Explicit current-owner release after recorded merged delivery; never takeover. + +This POSIX local-single-clone controller does not repair invalid projections, +remove worktrees, infer owner death, or approve publication. Plan is read-only. +""" +from contextlib import contextmanager +from datetime import datetime, timezone +import argparse +import base64 +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import secrets +import stat +import subprocess +import sys + +REQUEST_SCHEMA = 'new-project.terminal-owner-release/v1' +FIELDS = {'schema', 'requestId', 'worktreeId', 'ownerActor', 'ownerSession', 'ownerHandoff', + 'expectedRevision', 'expectedFencingToken', 'expectedLeaseSha256', 'terminalReceiptRef'} +MAX_BYTES = 1024 * 1024 + + +def require(condition, message): + if not condition: + raise PermissionError(message) + + +def sibling(name): + spec = importlib.util.spec_from_file_location('_terminal_owner_' + name, Path(__file__).with_name(name + '.py')) + value = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = value + spec.loader.exec_module(value) + return value + + +canonical = sibling('change_lease_check') +activity = sibling('ticket_activity') + + +def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, 'duplicate_json_key') + result[key] = value + return result + + +def decode(raw): + return json.loads(raw, object_pairs_hook=unique) + + +def encoded(value): + return (json.dumps(value, sort_keys=True, indent=2) + '\n').encode() + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def git(root, *args, check=True): + env = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')} + result = subprocess.run(['git', '-C', str(root), *args], env=env, capture_output=True, timeout=20) + if check: + require(result.returncode == 0, 'git_observation_unavailable') + return result.stdout.decode().strip() + + +@contextmanager +def directory(path, create=False): + """Keep all parents anchored; operations use the held directory descriptor.""" + require(os.name == 'posix' and hasattr(os, 'O_NOFOLLOW'), 'posix_controller_required') + path = Path(os.path.abspath(path)) + require('..' not in path.parts, 'unsafe_path') + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + descriptors = [os.open(path.anchor, flags)] + try: + for part in path.parts[1:]: + if create: + try: os.mkdir(part, 0o700, dir_fd=descriptors[-1]) + except FileExistsError: pass + descriptors.append(os.open(part, flags, dir_fd=descriptors[-1])) + def verify(): + for parent, child, part in zip(descriptors, descriptors[1:], path.parts[1:]): + named, held = os.stat(part, dir_fd=parent, follow_symlinks=False), os.fstat(child) + require(stat.S_ISDIR(named.st_mode) and (named.st_dev, named.st_ino) == (held.st_dev, held.st_ino), 'directory_identity_changed') + verify() + yield descriptors[-1], verify + finally: + for descriptor in reversed(descriptors): os.close(descriptor) + + +def read_at(fd, name, limit=MAX_BYTES, private=False): + descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=fd) + with os.fdopen(descriptor, 'rb') as stream: + info = os.fstat(stream.fileno()) + require(stat.S_ISREG(info.st_mode) and (not private or info.st_nlink == 1), 'regular_private_file_required') + raw = stream.read(limit + 1) + require(len(raw) <= limit, 'input_exceeds_bound') + return raw + + +def read_path(path): + with directory(path.parent) as (fd, verify): + raw = read_at(fd, path.name) + verify() + return decode(raw) + + +@contextmanager +def lease_lock(fd, name, verify): + import fcntl + descriptor = os.open(name + '.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600, dir_fd=fd) + try: + info = os.fstat(descriptor) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, 'unsafe_lock') + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + def verify_lock(): + verify() + named = os.stat(name + '.lock', dir_fd=fd, follow_symlinks=False) + require(stat.S_ISREG(named.st_mode) and named.st_nlink == 1 + and (named.st_dev, named.st_ino) == (info.st_dev, info.st_ino), 'lock_identity_changed') + verify_lock() + yield verify_lock + finally: + os.close(descriptor) + + +def atomic_at(fd, name, raw, exclusive=False): + temporary = '.terminal-' + secrets.token_hex(12) + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd) + try: + with os.fdopen(descriptor, 'wb') as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + if exclusive: + os.link(temporary, name, src_dir_fd=fd, dst_dir_fd=fd, follow_symlinks=False) + else: + os.replace(temporary, name, src_dir_fd=fd, dst_dir_fd=fd) + os.fsync(fd) + finally: + try: os.unlink(temporary, dir_fd=fd) + except FileNotFoundError: pass + + +def _replace_lease(fd, name, raw): + atomic_at(fd, name, raw) + + +def request_identity(request): + require(isinstance(request, dict) and set(request) == FIELDS, 'invalid_request_fields') + require(request['schema'] == REQUEST_SCHEMA and request['ownerHandoff'] is True, 'explicit_owner_handoff_required') + for key in ('ownerActor', 'ownerSession', 'requestId', 'terminalReceiptRef'): + require(isinstance(request[key], str) and 0 < len(request[key]) <= 160, 'invalid_request_identity') + for key in ('expectedRevision', 'expectedFencingToken'): + require(type(request[key]) is int and request[key] > 0, 'invalid_request_fencing') + require(isinstance(request['expectedLeaseSha256'], str) and re.fullmatch('[0-9a-f]{64}', request['expectedLeaseSha256']), 'invalid_request_digest') + value = request['worktreeId'] + match = re.fullmatch(r'(ticket-([0-9]{3,}))--([a-z0-9]+(?:-[a-z0-9]+)*)', value) if isinstance(value, str) else None + require(match is not None, 'invalid_worktree_identity') + return match[1], 'refs/heads/ticket/' + match[2] + '-' + match[3] + + +def _verify_terminal(root, request, lease): + # Activity receipts establish landed delivery, not approval authority. The + # current owner explicitly relinquishes only its own already-finished lease. + with activity.ActivityReadBatch(root): + policy = activity.load_policy(root) + rows = activity._validate_registry(activity._load(activity.registry_path(root, policy)), activity.repository_ref(root)) + receipt = next((r for r in rows if r['receiptRef'] == request['terminalReceiptRef']), None) + require(receipt is not None and receipt['ticket'] == lease['ticketId'] + and receipt['outcome'] == 'merged' and receipt['targetBranch'] == lease['targetBranch'], 'matching_merged_receipt_required') + rule = policy['terminalOutcomes'].get('merged') + require(rule and rule.get('releasesReservation') is True + and activity._terminal_verified(root, receipt, rule, activity._target_ref(root, receipt['targetBranch'])), 'terminal_delivery_unverified') + if lease['phase'] in {'approved', 'merged', 'closed'}: + require(lease['headSha'] == receipt['headSha'], 'publication_head_mismatch') + tree = root / '.worktrees' / lease['worktreeId'] + branch_head = git(root, 'rev-parse', '--verify', lease['branchRef'], check=False) + require((branch_head or lease['headSha']) == receipt['headSha'], 'terminal_head_mismatch') + if tree.exists(): + with directory(tree): pass + require(git(tree, 'rev-parse', 'HEAD') == receipt['headSha'] + and git(tree, 'symbolic-ref', 'HEAD') == lease['branchRef'] + and not git(tree, 'status', '--porcelain', '--untracked-files=all'), 'worktree_has_unpublished_work') + return receipt + + +def replacement(lease, request, stamp): + require(not canonical.validate_lease(lease), 'invalid_canonical_lease') + phase = lease['phase'] + if phase in {'claimed', 'editing', 'validating'}: actions = ['cancel', 'release'] + elif phase == 'approved': actions = ['record-merge', 'close', 'release'] + elif phase == 'merged': actions = ['close', 'release'] + elif phase in {'closed', 'cancelled'}: actions = ['release'] + else: raise PermissionError('phase_requires_existing_controller_reconciliation') + current, receipts = dict(lease), [] + for action in actions: + transition = {'schema': canonical.REQUEST_SCHEMA, 'requestId': sha(encoded(request)) + '-' + action, + 'leaseId': current['leaseId'], 'action': action, 'expectedRevision': current['leaseRevision'], + 'expectedFencingToken': current['fencingToken'], 'expectedPhase': current['phase'], + 'requestedBy': request['ownerActor'], 'idempotencyKey': request['requestId'] + ':' + action, + 'targetHeadSha': current['headSha'], 'replacementReceiptRef': None, + 'authorityRef': request['terminalReceiptRef'], 'requestedAt': stamp} + receipt, errors = canonical.evaluate_transition(current, transition) + require(not errors and receipt is not None, 'canonical_transition_refused') + current.update(phase=receipt['phaseAfter'], leaseRevision=receipt['leaseRevision'], + fencingToken=receipt['fencingToken'], eventSequence=current['eventSequence'] + 1, + publicationFrozen=receipt['phaseAfter'] in canonical.FROZEN_PHASES, previousReceiptRef=receipt['receiptRef']) + receipts.append(receipt) + require(not canonical.validate_lease(current), 'invalid_release_result') + return current, receipts + + +def _execute(root, request, fd, verify, name, apply): + raw = read_at(fd, name, canonical.MAX_LEASE_BYTES, private=True) + current = decode(raw) + require(not canonical.validate_lease(current), 'invalid_canonical_lease') + ticket, branch = request_identity(request) + expected = {'ticketId': ticket, 'worktreeId': request['worktreeId'], 'branchRef': branch, + 'repositoryRef': 'local/' + sha(str(root).encode()), + 'ownerActor': request['ownerActor'], 'ownerSession': request['ownerSession']} + require(all(current[k] == v for k, v in expected.items()), 'current_owner_or_repository_mismatch') + journal_path = root / '.subactor/receipts/terminal-owner-release' / (sha(encoded(request)) + '.json') + if journal_path.exists() or journal_path.is_symlink(): + journal = read_path(journal_path) + original = base64.b64decode(journal['original'], validate=True) + require(journal['request'] == request and sha(original) == request['expectedLeaseSha256'], 'journal_request_mismatch') + prior = decode(original) + require(prior['leaseRevision'] == request['expectedRevision'] + and prior['fencingToken'] == request['expectedFencingToken'], 'journal_fencing_mismatch') + target, receipts = replacement(prior, request, journal['stamp']) + require(journal['replacement'] == target and journal['transitions'] == receipts, 'journal_transition_mismatch') + if raw == encoded(target): + return {'status': 'idempotent', 'leaseRevision': target['leaseRevision'], 'fencingToken': target['fencingToken']} + require(raw == original, 'journal_lease_compare_and_swap_lost') + else: + require(sha(raw) == request['expectedLeaseSha256'] and current['leaseRevision'] == request['expectedRevision'] + and current['fencingToken'] == request['expectedFencingToken'], 'lease_compare_and_swap_lost') + stamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + target, receipts = replacement(current, request, stamp) + journal = {'schema': REQUEST_SCHEMA, 'request': request, 'original': base64.b64encode(raw).decode(), + 'replacement': target, 'transitions': receipts, 'stamp': stamp} + _verify_terminal(root, request, current) + verify() + require(read_at(fd, name, canonical.MAX_LEASE_BYTES, private=True) == raw, 'lease_compare_and_swap_lost') + if not apply: + return {'status': 'planned', 'grantsAuthority': False, 'actions': [r['action'] for r in receipts]} + with directory(journal_path.parent, create=True) as (journal_fd, verify_journal): + try: atomic_at(journal_fd, journal_path.name, encoded(journal), exclusive=True) + except FileExistsError: + require(read_at(journal_fd, journal_path.name) == encoded(journal), 'journal_changed') + verify_journal() + verify() + require(read_at(fd, name, canonical.MAX_LEASE_BYTES, private=True) == raw, 'lease_compare_and_swap_lost') + _replace_lease(fd, name, encoded(target)) + verify() + return {'status': 'released', 'leaseRevision': target['leaseRevision'], 'fencingToken': target['fencingToken'], + 'receipt': str(journal_path), 'worktreeCleanup': 'separate_controller_required'} + + +def release_owner(root, request, *, apply=False, actor=None, session=None): + request_identity(request) + require(os.name == 'posix' and hasattr(os, 'O_NOFOLLOW'), 'posix_controller_required') + if apply: + require(actor == request['ownerActor'] and session == request['ownerSession'], 'invoking_owner_context_required') + root = Path(root).absolute() + with directory(root): pass + root = Path(git(root, 'worktree', 'list', '--porcelain').splitlines()[0].removeprefix('worktree ')) + require(root.is_absolute(), 'primary_checkout_unavailable') + policy = root / '.governance/ticket-allocation.json' + if not policy.exists(): policy = root / 'governance/ticket-allocation.json' + config = read_path(policy) + require(isinstance(config, dict) and set(config) == {'$schema', 'schema', 'mode'} + and config['schema'] == 'new-project.ticket-allocation/v1' + and config['mode'] == 'local-single-clone', 'registered_controller_required') + with directory(root / '.subactor/leases') as (fd, verify_directory): + def verify(): + verify_directory() + require(read_path(policy) == config, 'controller_policy_changed') + name = request['worktreeId'] + '.json' + if apply: + with lease_lock(fd, name, verify) as verify_lock: + return _execute(root, request, fd, verify_lock, name, True) + return _execute(root, request, fd, verify, name, False) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, default=Path.cwd()) + parser.add_argument('--request', type=Path, required=True) + parser.add_argument('--apply', action='store_true') + args = parser.parse_args() + try: + print(json.dumps(release_owner(args.root, read_path(args.request.absolute()), apply=args.apply, + actor=os.environ.get('NEW_PROJECT_WRITER_ACTOR'), session=os.environ.get('NEW_PROJECT_WRITER_SESSION')))) + return 0 + except (OSError, ValueError, TypeError, KeyError, PermissionError, subprocess.SubprocessError, activity.ActivityError): + print('GOV-CHANGE-LEASE-002: terminal owner release refused; preserve evidence and reconcile the exact owner, CAS, receipt and controller.', file=sys.stderr) + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.governance/ticket-activity-override.schema.json b/.governance/ticket-activity-override.schema.json new file mode 100644 index 0000000..c34fa7d --- /dev/null +++ b/.governance/ticket-activity-override.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.dev/schemas/new-project/ticket-activity-override.v1.schema.json", + "title": "Target-owned ticket activity policy override", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema", "missingPolicy"], + "properties": { + "$schema": {"const": "./ticket-activity-override.schema.json"}, + "schema": {"const": "new-project.ticket-activity-override/v1"}, + "missingPolicy": {"enum": ["status-projection", "git-ancestry"]} + } +} diff --git a/.governance/ticket-activity.json b/.governance/ticket-activity.json new file mode 100644 index 0000000..14332b7 --- /dev/null +++ b/.governance/ticket-activity.json @@ -0,0 +1,20 @@ +{ + "$schema": "./ticket-activity.schema.json", + "schema": "new-project.ticket-activity/v1", + "registry": { + "location": "git-common-dir", + "path": "new-project/terminal-receipts.json", + "missingPolicy": "status-projection" + }, + "terminalOutcomes": { + "merged": { + "verification": "git-ancestry-or-rewritten-patch-series", + "releasesReservation": true + }, + "no-change": { + "verification": "git-ancestry", + "releasesReservation": true + } + }, + "unsupportedOutcomePolicy": "remain-active" +} diff --git a/.governance/ticket-activity.schema.json b/.governance/ticket-activity.schema.json new file mode 100644 index 0000000..c6aa4ab --- /dev/null +++ b/.governance/ticket-activity.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.dev/schemas/new-project/ticket-activity.v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema", "registry", "terminalOutcomes", "unsupportedOutcomePolicy"], + "properties": { + "$schema": {"const": "./ticket-activity.schema.json"}, + "schema": {"const": "new-project.ticket-activity/v1"}, + "registry": { + "type": "object", + "additionalProperties": false, + "required": ["location", "path", "missingPolicy"], + "properties": { + "location": {"const": "git-common-dir"}, + "path": {"type": "string", "pattern": "^[A-Za-z0-9._/-]+$"}, + "missingPolicy": {"enum": ["status-projection", "git-ancestry"], + "description": "How to resolve a ticket when the receipt registry is absent. status-projection trusts the README status and is conservative. git-ancestry additionally asks Git whether the delivery already reached the target branch, which an adopter chooses when hand-edited statuses have stopped tracking reality."} + } + }, + "terminalOutcomes": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["verification", "releasesReservation"], + "properties": { + "verification": { + "enum": [ + "git-ancestry", + "git-ancestry-or-rewritten-patch-series" + ] + }, + "releasesReservation": {"const": true} + } + } + }, + "unsupportedOutcomePolicy": {"const": "remain-active"} + } +} diff --git a/.governance/ticket-allocation-receipt.schema.json b/.governance/ticket-allocation-receipt.schema.json new file mode 100644 index 0000000..80f4ea2 --- /dev/null +++ b/.governance/ticket-allocation-receipt.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/new-project-ticket-allocation-receipt-v1.json", + "title": "new-project registered ticket allocation receipt", + "type": "object", + "additionalProperties": false, + "required": ["schema", "allocationId", "repositoryRef", "ticket", "number", "requestDigest", "processUri", "issuer", "fencingToken", "issuedAt", "expiresAt", "receiptRef", "proofDigest"], + "properties": { + "schema": {"const": "new-project.ticket-allocation-receipt/v1"}, + "allocationId": {"type": "string", "pattern": "^[a-z][a-z0-9+.-]*://\\S+$"}, + "repositoryRef": {"type": "string", "pattern": "^[a-z][a-z0-9+.-]*://\\S+$"}, + "ticket": {"type": "string", "pattern": "^ticket-[0-9]{3,}$"}, + "number": {"type": "integer", "minimum": 1}, + "requestDigest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "processUri": {"type": "string", "pattern": "^[a-z][a-z0-9+.-]*://\\S+$"}, + "issuer": {"type": "string", "pattern": "^[a-z][a-z0-9+.-]*://\\S+$"}, + "fencingToken": {"type": "integer", "minimum": 1}, + "issuedAt": {"type": "string", "format": "date-time"}, + "expiresAt": {"type": "string", "format": "date-time"}, + "receiptRef": {"type": "string", "pattern": "^[a-z][a-z0-9+.-]*://\\S+$"}, + "proofDigest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"} + } +} diff --git a/.governance/ticket-allocation-request.schema.json b/.governance/ticket-allocation-request.schema.json new file mode 100644 index 0000000..6b02160 --- /dev/null +++ b/.governance/ticket-allocation-request.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/new-project-ticket-allocation-request-v1.json", + "title": "new-project registered ticket allocation request", + "type": "object", + "additionalProperties": false, + "required": ["schema", "repositoryRef", "allocationKey", "titleDigest", "agent", "workstream", "classification", "processUri"], + "properties": { + "schema": {"const": "new-project.ticket-allocation-request/v1"}, + "repositoryRef": {"type": "string", "pattern": "^[a-z][a-z0-9+.-]*://\\S+$"}, + "allocationKey": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$"}, + "titleDigest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "agent": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$"}, + "workstream": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"}, + "classification": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "priority", "origin"], + "properties": { + "kind": {"enum": ["BUG", "FEATURE", "SERVICE"]}, + "priority": {"enum": ["P0", "P1", "P2", "P3"]}, + "origin": {"enum": ["regression", "requested", "health"]} + } + }, + "processUri": {"type": "string", "pattern": "^[a-z][a-z0-9+.-]*://\\S+$"} + } +} diff --git a/.governance/ticket-allocation.json b/.governance/ticket-allocation.json new file mode 100644 index 0000000..f07473a --- /dev/null +++ b/.governance/ticket-allocation.json @@ -0,0 +1,5 @@ +{ + "$schema": "./ticket-allocation.schema.json", + "mode": "local-single-clone", + "schema": "new-project.ticket-allocation/v1" +} diff --git a/.governance/ticket-allocation.schema.json b/.governance/ticket-allocation.schema.json new file mode 100644 index 0000000..c1c5f14 --- /dev/null +++ b/.governance/ticket-allocation.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.com/schemas/new-project-ticket-allocation-v1.json", + "title": "new-project ticket allocation policy", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema", "mode"], + "properties": { + "$schema": {"type": "string", "minLength": 1}, + "schema": {"const": "new-project.ticket-allocation/v1"}, + "mode": {"const": "local-single-clone"} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema", "mode", "allocator"], + "properties": { + "$schema": {"type": "string", "minLength": 1}, + "schema": {"const": "new-project.ticket-allocation/v1"}, + "mode": {"const": "registered"}, + "allocator": { + "type": "object", + "additionalProperties": false, + "required": ["processUri", "issuer", "maxReceiptAgeSeconds"], + "properties": { + "processUri": {"type": "string", "pattern": "^[a-z][a-z0-9+.-]*://\\S+$"}, + "issuer": {"type": "string", "pattern": "^[a-z][a-z0-9+.-]*://\\S+$"}, + "maxReceiptAgeSeconds": {"type": "integer", "minimum": 30, "maximum": 3600} + } + } + } + } + ] +} diff --git a/.governance/ticket_activity.py b/.governance/ticket_activity.py new file mode 100755 index 0000000..46d3fc7 --- /dev/null +++ b/.governance/ticket_activity.py @@ -0,0 +1,688 @@ +#!/usr/bin/env python3 +"""Resolve reservations through ancestry or verified rewritten Git patches.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +from contextvars import ContextVar +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +SHA_RE = re.compile(r"^[a-f0-9]{40}$") +TICKET_RE = re.compile(r"^ticket-[0-9]{3,}$") +RECEIPT_REF_RE = re.compile(r"^receipt:\S+$") +TARGET_BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]+$") +OCCURRED_AT_RE = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$" +) +REWRITE_VERIFICATION = "git-ancestry-or-rewritten-patch-series" +DEFAULT_TARGET_BRANCH = "main" +# status-projection stays the conservative default: an absent registry must not +# be guessed away. git-ancestry is opt-in, for an adopter whose hand-edited +# statuses have stopped tracking reality. +MISSING_POLICIES = ("status-projection", "git-ancestry") + + +class ActivityError(RuntimeError): + code = "GOV-TICKET-ACTIVITY-001" + + +class ActivityPolicyMissing(ActivityError): + """The repository predates adoption of the managed activity contract.""" + + +@dataclass(frozen=True) +class ActivityResolution: + ticket: str + active: bool + projectionStatus: str | None + authority: str + receiptRef: str | None = None + reason: str | None = None + + +_READ_BATCH: ContextVar[ActivityReadBatch | None] = ContextVar("activity_read_batch", default=None) + + +class ActivityReadBatch: + """One checkout's read-only observations; no data survives context exit. + + Re-read consulted Git queries and files before accepting any inactivity. + Context-local storage keeps concurrent/nested inspectors and clones apart. + An invalidated batch raises the ordinary fail-closed activity diagnostic. + """ + + def __init__(self, root: Path): + self.root = root.resolve() + self.queries = {} + self.files = {} + self.context_reset = None + + def __enter__(self): + if self.context_reset is not None: + raise ActivityError("activity read batch is already open") + self.queries.clear() + self.files.clear() + self.context_reset = _READ_BATCH.set(self) + try: + # Fence checkout identity, HEAD and registration even when every + # historical receipt belongs to a different ticket branch. + _git(self.root, "rev-parse", "--path-format=absolute", "--git-common-dir", check=False) + _git(self.root, "rev-parse", "--verify", "HEAD", check=False) + _git(self.root, "worktree", "list", "--porcelain", check=False) + self.directory_names = self._directories() + except BaseException as error: + _READ_BATCH.reset(self.context_reset) + self.context_reset = None + if isinstance(error, OSError): + raise ActivityError("activity inputs unavailable during inspection") from error + raise + return self + + def _directories(self): + project = self.root / "project" + return sorted(p.name for p in project.iterdir()) if project.is_dir() else None + + def __exit__(self, kind, value, traceback): + _READ_BATCH.reset(self.context_reset) + self.context_reset = None + try: + if kind is None: + if self.directory_names != self._directories(): + raise ActivityError("ticket inventory changed during activity inspection; retry") + for (args, check), expected in self.queries.items(): + if _run_git(self.root, *args, check=check) != expected: + raise ActivityError("Git state changed during activity inspection; retry") + for (path, operation), expected in self.files.items(): + if _file_observation(path, operation) != expected: + raise ActivityError("activity document changed during inspection; retry") + except OSError as error: + raise ActivityError("activity inputs unavailable during revalidation") from error + finally: + self.queries.clear() + self.files.clear() + + +def _file_observation(path: Path, operation: str): + if operation == "read_text": + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + return getattr(path, operation)() + + +def _file(path: Path, operation: str): + batch = _READ_BATCH.get() + if batch is None: + return _file_observation(path, operation) + key = (path.absolute(), operation) + if key not in batch.files: + batch.files[key] = _file_observation(*key) + return batch.files[key] + + +def _read_text(path: Path) -> str: + value = _file(path, "read_text") + if value is None: + raise FileNotFoundError(path) + return value + + +def _git(root: Path, *args: str, check: bool = True) -> str: + batch = _READ_BATCH.get() + if batch is None: + return _run_git(root, *args, check=check) + if root.resolve() != batch.root: + raise ActivityError("activity read batch cannot cross checkouts") + key = (args, check) + if key not in batch.queries: + batch.queries[key] = _run_git(root, *args, check=check) + return batch.queries[key] + + +def _run_git(root: Path, *args: str, check: bool = True) -> str: + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + result = subprocess.run( + ["git", "-C", str(root), *args], capture_output=True, text=True, + check=False, timeout=20, env=env, + ) + if check and result.returncode: + raise ActivityError((result.stderr or result.stdout).strip() or "Git verification failed") + return result.stdout.strip() if result.returncode == 0 else "" + + +def _load(path: Path) -> Any: + try: + return json.loads(_read_text(path)) + except (OSError, json.JSONDecodeError) as error: + raise ActivityError(f"invalid activity document {path}: {error}") from error + + +def policy_path(root: Path) -> Path: + for candidate in (root / ".governance/ticket-activity.json", root / "governance/ticket-activity.json"): + if _file(candidate, "is_file"): + return candidate + raise ActivityPolicyMissing("managed ticket activity policy is missing") + + +def override_path(root: Path) -> Path | None: + for candidate in ( + root / ".governance/ticket-activity.override.json", + root / "governance/ticket-activity.override.json", + ): + if _file(candidate, "is_file"): + return candidate + return None + + +def apply_override(root: Path, value: dict[str, Any]) -> dict[str, Any]: + path = override_path(root) + if path is None: + return value + override = _load(path) + if ( + not isinstance(override, dict) + or set(override) != {"$schema", "schema", "missingPolicy"} + or override.get("$schema") != "./ticket-activity-override.schema.json" + or override.get("schema") != "new-project.ticket-activity-override/v1" + or override.get("missingPolicy") not in MISSING_POLICIES + ): + raise ActivityError("target-owned ticket activity override is invalid") + effective = dict(value) + effective["registry"] = dict(value["registry"]) + effective["registry"]["missingPolicy"] = override["missingPolicy"] + return effective + + +def load_policy(root: Path) -> dict[str, Any]: + value = _load(policy_path(root)) + required = {"$schema", "schema", "registry", "terminalOutcomes", "unsupportedOutcomePolicy"} + if not isinstance(value, dict) or set(value) != required or value.get("schema") != "new-project.ticket-activity/v1": + raise ActivityError("managed ticket activity policy has unsupported fields or schema") + registry = value.get("registry") + if not isinstance(registry, dict) or set(registry) != {"location", "path", "missingPolicy"}: + raise ActivityError("managed ticket activity registry declaration is invalid") + if registry.get("location") != "git-common-dir" or registry.get("missingPolicy") not in MISSING_POLICIES: + raise ActivityError("managed ticket activity registry policy is unsupported") + raw_path = registry.get("path") + if not isinstance(raw_path, str) or not raw_path or Path(raw_path).is_absolute() or ".." in Path(raw_path).parts: + raise ActivityError("managed terminal receipt registry path is unsafe") + _validate_terminal_outcomes(value) + if value.get("unsupportedOutcomePolicy") != "remain-active": + raise ActivityError("unsupported outcome policy must remain-active") + return apply_override(root, value) + + +def _validate_terminal_outcomes(value): + outcomes = value.get("terminalOutcomes") + if not isinstance(outcomes, dict) or not outcomes: + raise ActivityError("managed terminal outcomes are missing") + supported_verification = {"git-ancestry", REWRITE_VERIFICATION} + for name, rule in outcomes.items(): + if ( + not isinstance(name, str) + or not name + or not isinstance(rule, dict) + or set(rule) != {"verification", "releasesReservation"} + or rule.get("verification") not in supported_verification + or rule.get("releasesReservation") is not True + ): + raise ActivityError("managed terminal outcome rule is unsupported") + + +def registry_path(root: Path, policy: dict[str, Any] | None = None) -> Path: + selected = policy or load_policy(root) + raw_common = _git(root, "rev-parse", "--path-format=absolute", "--git-common-dir", check=False) + # Scaffolder fixtures and pre-init directories cannot possess terminal Git + # evidence. An absent synthetic location therefore has the same safe result + # as an absent optional registry: retain the status projection. + common = Path(raw_common).resolve() if raw_common else (root / ".git").resolve() + return common / selected["registry"]["path"] + + +def repository_ref(root: Path) -> str: + remote = _git(root, "remote", "get-url", "origin", check=False) + if remote: + return remote.strip().removesuffix(".git").rstrip("/").lower() + common = Path(_git(root, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve() + return f"local:{common}" + + +def _ticket_state_fields(text: str) -> tuple[set[str], set[str]]: + """Read explicit metadata, including the historical inline header. + + A status projection never grants authority. Ignore fenced examples and + refuse contradictory declarations rather than select whichever comes first. + """ + statuses: set[str] = set() + states: set[str] = set() + fence: str | None = None + fence_length = 0 + for raw_line in text.split("\n"): + line = raw_line.removesuffix("\r") + delimiter = re.match(r"^[ \t]{0,3}(`{3,}|~{3,})(.*)$", line) + if fence is not None: + if (delimiter and delimiter.group(1)[0] == fence + and len(delimiter.group(1)) >= fence_length + and not delimiter.group(2).strip()): + fence = None + continue + if delimiter: + fence = delimiter.group(1)[0] + fence_length = len(delimiter.group(1)) + continue + status = re.fullmatch(r"-[ \t]+\*\*Status\*\*:[ \t]*([A-Z_]+)[ \t]*", line, re.I) + workflow = re.fullmatch(r"-[ \t]+\*\*Workflow state\*\*:[ \t]*([A-Z_]+)[ \t]*", line, re.I) + legacy = re.fullmatch( + r"Status:[ \t]*([A-Z_]+)(?:[ \t]*/[ \t]*([A-Z_]+))?[ \t]*\.?[ \t]*", + line, re.I, + ) + if status: + statuses.add(status.group(1).upper()) + if workflow: + states.add(workflow.group(1).upper()) + if legacy: + statuses.add(legacy.group(1).upper()) + if legacy.group(2): + states.add(legacy.group(2).upper()) + return statuses, states + + +def parse_ticket_state_text(text: str) -> tuple[str | None, str | None]: + statuses, states = _ticket_state_fields(text) + if len(statuses) > 1 or len(states) > 1: + return None, None + return next(iter(statuses), None), next(iter(states), None) + + +def projection_status(ticket_dir: Path) -> str | None: + try: + text = _read_text(ticket_dir / "README.md") + except OSError: + return None + statuses, states = _ticket_state_fields(text) + if len(statuses) > 1 or len(states) > 1: + raise ActivityError("ticket status or workflow declarations conflict") + return next(iter(statuses), None) + + +def _validate_terminal_receipt(receipt, seen): + fields = {"receiptRef", "ticket", "outcome", "headSha", "terminalSha", "targetBranch", "occurredAt"} + if not isinstance(receipt, dict) or set(receipt) != fields: + raise ActivityError("terminal receipt fields are invalid") + if not isinstance(receipt.get("receiptRef"), str) or RECEIPT_REF_RE.fullmatch(receipt["receiptRef"]) is None: + raise ActivityError("terminal receipt reference is invalid") + if receipt["receiptRef"] in seen: + raise ActivityError("terminal receipt references are not unique") + seen.add(receipt["receiptRef"]) + if not TICKET_RE.fullmatch(receipt.get("ticket", "")): + raise ActivityError("terminal receipt ticket is invalid") + if not SHA_RE.fullmatch(receipt.get("headSha", "")) or not SHA_RE.fullmatch(receipt.get("terminalSha", "")): + raise ActivityError("terminal receipt SHA binding is invalid") + if not isinstance(receipt.get("outcome"), str) or not receipt["outcome"]: + raise ActivityError("terminal receipt value is blank") + if TARGET_BRANCH_RE.fullmatch(receipt.get("targetBranch", "")) is None: + raise ActivityError("terminal receipt target branch is invalid") + if OCCURRED_AT_RE.fullmatch(receipt.get("occurredAt", "")) is None: + raise ActivityError("terminal receipt timestamp is invalid") + + +def _validate_registry(value: Any, expected_repository: str) -> list[dict[str, str]]: + if not isinstance(value, dict) or set(value) != {"schema", "repositoryRef", "receipts"}: + raise ActivityError("terminal receipt registry fields are invalid") + if value.get("schema") != "new-project.terminal-receipt-registry/v1": + raise ActivityError("terminal receipt registry schema is unsupported") + if value.get("repositoryRef") != expected_repository: + raise ActivityError("terminal receipt registry belongs to another repository") + receipts = value.get("receipts") + if not isinstance(receipts, list): + raise ActivityError("terminal receipt registry receipts must be a list") + seen: set[str] = set() + for receipt in receipts: + _validate_terminal_receipt(receipt, seen) + return receipts + + +def _ancestor(root: Path, older: str, newer: str) -> bool: + result = subprocess.run( + ["git", "-C", str(root), "merge-base", "--is-ancestor", older, newer], + capture_output=True, check=False, timeout=20, + env={key: value for key, value in os.environ.items() if not key.startswith("GIT_")}, + ) + return result.returncode == 0 + + +def _linear_series(root: Path, base: str, head: str) -> list[str] | None: + """Return the exact single-parent chain from base to head, or fail closed.""" + raw = _git(root, "rev-list", "--reverse", "--ancestry-path", f"{base}..{head}", check=False) + commits = raw.splitlines() if raw else [] + if not commits: + return None + previous = base + for commit in commits: + parents = _git(root, "show", "-s", "--format=%P", commit, check=False).split() + if parents != [previous]: + return None + previous = commit + return commits if previous == head else None + + +def _stable_patch_id(payload: bytes, env: dict[str, str]) -> str | None: + """Return Git's stable patch identity for one non-empty diff payload.""" + identified = subprocess.run( + ["git", "patch-id", "--stable"], + input=payload, + capture_output=True, + check=False, + timeout=20, + env=env, + ) + if identified.returncode: + return None + fields = identified.stdout.decode("utf-8", errors="strict").split() + return fields[0] if len(fields) >= 2 and SHA_RE.fullmatch(fields[0]) else None + + +# project/readme.sh regenerates this index from the ticket directories. A +# squash or rebase onto a target where a sibling already added the same rows +# drops its hunk, so patch identity covers every authored path but not it. +GENERATED_INDEX_PATHSPEC = ("--", ":(top)", ":(top,exclude)project/TICKETS.md") + + +def _patch_id(root: Path, commit: str) -> str | None: + """Return Git's stable patch identity for one non-empty commit.""" + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + shown = subprocess.run( + [ + "git", + "-C", + str(root), + "show", + "--no-ext-diff", + "--pretty=format:%H", + "--binary", + "--full-index", + commit, + *GENERATED_INDEX_PATHSPEC, + ], + capture_output=True, + check=False, + timeout=20, + env=env, + ) + if shown.returncode: + return None + return _stable_patch_id(shown.stdout, env) + + +def _range_patch_id(root: Path, base: str, head: str) -> str | None: + """Return the stable identity of the aggregate tree change in one range.""" + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + diff = subprocess.run( + [ + "git", + "-C", + str(root), + "diff", + "--no-ext-diff", + "--binary", + "--full-index", + base, + head, + *GENERATED_INDEX_PATHSPEC, + ], + capture_output=True, + check=False, + timeout=20, + env=env, + ) + if diff.returncode: + return None + return _stable_patch_id(diff.stdout, env) + + +def _rebased_patch_series(root: Path, head_sha: str, terminal_sha: str) -> bool: + """Verify a GitHub-style linear rebase without trusting an asserted method.""" + base = _git(root, "merge-base", head_sha, terminal_sha, check=False) + if not SHA_RE.fullmatch(base): + return False + original = _linear_series(root, base, head_sha) + if original is None: + return False + terminal_base = _git(root, "rev-parse", f"{terminal_sha}~{len(original)}", check=False) + if not SHA_RE.fullmatch(terminal_base): + return False + rebased = _linear_series(root, terminal_base, terminal_sha) + if rebased is None or len(rebased) != len(original): + return False + original_ids = [_patch_id(root, commit) for commit in original] + rebased_ids = [_patch_id(root, commit) for commit in rebased] + return None not in original_ids and original_ids == rebased_ids + + +def _squashed_patch(root: Path, head_sha: str, terminal_sha: str) -> bool: + """Verify one squash commit against the aggregate protected-head change.""" + base = _git(root, "merge-base", head_sha, terminal_sha, check=False) + parents = _git(root, "show", "-s", "--format=%P", terminal_sha, check=False).split() + if not SHA_RE.fullmatch(base) or len(parents) != 1: + return False + original_id = _range_patch_id(root, base, head_sha) + terminal_id = _range_patch_id(root, parents[0], terminal_sha) + return original_id is not None and original_id == terminal_id + + +def _rewritten_patch(root: Path, head_sha: str, terminal_sha: str) -> bool: + return _rebased_patch_series(root, head_sha, terminal_sha) or _squashed_patch( + root, head_sha, terminal_sha + ) + + +def _terminal_verified( + root: Path, + receipt: dict[str, str], + rule: dict[str, Any] | None, + target: str | None, +) -> bool: + if rule is None or target is None: + return False + head_sha, terminal_sha = receipt["headSha"], receipt["terminalSha"] + if not _ancestor(root, terminal_sha, target): + return False + integrated = _ancestor(root, head_sha, terminal_sha) + if not integrated and rule.get("verification") == REWRITE_VERIFICATION: + integrated = _rewritten_patch(root, head_sha, terminal_sha) + return integrated and not _advanced_ticket_branch( + root, receipt["ticket"], head_sha, terminal_sha + ) + + +def _target_ref(root: Path, branch: str) -> str | None: + for ref in (f"refs/remotes/origin/{branch}", f"refs/heads/{branch}"): + sha = _git(root, "rev-parse", "--verify", ref, check=False) + if sha: + return sha + return None + + +def _ticket_branch_matches(branch: str, ticket: str) -> bool: + number = str(int(ticket.removeprefix("ticket-"))) + return re.search(rf"(?:^|[^0-9a-z])ticket[-_/]?0*{number}(?:[^0-9a-z]|$)", branch, re.IGNORECASE) is not None + + +def _advanced_ticket_branch(root: Path, ticket: str, head_sha: str, terminal_sha: str) -> bool: + branch = _git(root, "branch", "--show-current", check=False) + if not branch or not _ticket_branch_matches(branch, ticket): + return False + current = _git(root, "rev-parse", "HEAD", check=False) + return bool(current and current != head_sha and _ancestor(root, head_sha, current) and not _ancestor(root, current, terminal_sha)) + + +def _unmerged_ticket_branch(root: Path, ticket: str, target: str) -> bool: + """Keep supported outstanding ticket branches active from a strict inventory.""" + listed = _git(root, "for-each-ref", "--format=%(refname) %(objectname)", + "refs/remotes/origin", "refs/heads") + for entry in listed.splitlines(): + ref, _, head = entry.partition(" ") + branch = ref.removeprefix("refs/remotes/origin/").removeprefix("refs/heads/") + if _ticket_branch_matches(branch, ticket) and not _ancestor(root, head, target): + return True + return False + + + +def delivery_landed(root: Path, ticket_dir: Path, target: str) -> bool: + """Answer from Git whether this ticket's delivery is already on the target. + + The policy declares Git ancestry as the verification for a merged outcome, + but ``resolve`` could apply it only to a ticket that already had a receipt. + The receipt registry lives in the Git common directory, is untracked, and is + therefore usually absent, so a ticket merged through an ordinary pull + request stayed projected active for the rest of the repository's life. + + Measured on 2026-09-09 across four adopters: 54, 65, 153 and 182 tickets + projected active at once, with merged deliveries among them. Every rule that + filters on "active" — conflict detection, allocation refusal, reservation + release — was reasoning over that noise, which is why declaring a conflict + never helped anyone. + + A ticket's own directory is committed together with its delivery, because a + commit carrying only tracking carriers is refused. Its presence on the + target ref is therefore the ancestry evidence the policy asks for. A branch + for the same ticket that the target does not yet contain means more of the + delivery is still in flight, and the ticket stays active. + """ + try: + relative = ticket_dir.resolve().relative_to(root.resolve()).as_posix() + except ValueError: + return False + present = subprocess.run( + ["git", "-C", str(root), "cat-file", "-e", f"{target}:{relative}"], + capture_output=True, check=False, timeout=20, + env={key: value for key, value in os.environ.items() if not key.startswith("GIT_")}, + ) + if present.returncode != 0: + return False + return not _unmerged_ticket_branch(root, ticket_dir.name, target) + + +def resolve(root: Path, ticket_dir: Path, active_statuses: set[str], *, status_override: str | None = None) -> ActivityResolution: + root = root.resolve() + ticket = ticket_dir.name + status = projection_status(ticket_dir) if status_override is None else status_override + projected_active = status in active_statuses + if not projected_active: + return ActivityResolution(ticket, False, status, "status-projection", reason="projection-not-active") + try: + policy = load_policy(root) + except ActivityPolicyMissing: + return ActivityResolution(ticket, True, status, "status-projection", reason="policy-not-adopted") + derive = policy["registry"]["missingPolicy"] == "git-ancestry" + default_target = _target_ref(root, DEFAULT_TARGET_BRANCH) if derive else None + path = registry_path(root, policy) + if not _file(path, "exists"): + if default_target and delivery_landed(root, ticket_dir, default_target): + return ActivityResolution( + ticket, False, status, "git-ancestry", reason="delivery-on-target", + ) + return ActivityResolution(ticket, True, status, "status-projection", reason="registry-absent") + receipts = _validate_registry(_load(path), repository_ref(root)) + matching = [item for item in receipts if item["ticket"] == ticket] + for receipt in reversed(matching): + rule = policy["terminalOutcomes"].get(receipt["outcome"]) + if rule is None: + continue + target = _target_ref(root, receipt["targetBranch"]) + if not _terminal_verified(root, receipt, rule, target): + continue + return ActivityResolution(ticket, False, status, "terminal-receipt", receipt["receiptRef"], "verified-terminal") + if default_target and delivery_landed(root, ticket_dir, default_target): + return ActivityResolution( + ticket, False, status, "git-ancestry", reason="delivery-on-target", + ) + return ActivityResolution(ticket, True, status, "status-projection", reason="no-verifiable-terminal-receipt") + + +def record(root: Path, receipt: dict[str, str]) -> Path: + if _READ_BATCH.get() is not None: + raise ActivityError("activity read batch cannot record receipts") + policy = load_policy(root) + path = registry_path(root, policy) + current: dict[str, Any] + if path.exists(): + current = _load(path) + _validate_registry(current, repository_ref(root)) + else: + current = {"schema": "new-project.terminal-receipt-registry/v1", "repositoryRef": repository_ref(root), "receipts": []} + prior = next( + (item for item in current["receipts"] if item["receiptRef"] == receipt.get("receiptRef")), + None, + ) + if prior is not None and prior != receipt: + raise ActivityError("terminal receipt reference is append-only and already binds different evidence") + candidate = dict(current) + candidate["receipts"] = list(current["receipts"]) + if prior is None: + candidate["receipts"].append(receipt) + _validate_registry(candidate, repository_ref(root)) + rule = policy["terminalOutcomes"].get(receipt["outcome"]) + target = _target_ref(root, receipt["targetBranch"]) + if not _terminal_verified(root, receipt, rule, target): + raise ActivityError("receipt does not verify against the managed outcome policy and current Git ancestry") + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix="terminal-receipts.", suffix=".json", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(candidate, stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + return path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path.cwd()) + sub = parser.add_subparsers(dest="command", required=True) + resolver = sub.add_parser("resolve") + resolver.add_argument("--ticket-dir", type=Path, required=True) + resolver.add_argument("--active-status", action="append", required=True) + sub.add_parser("validate") + recorder = sub.add_parser("record") + recorder.add_argument("--receipt", type=Path, required=True) + args = parser.parse_args() + try: + if args.command == "resolve": + result = resolve(args.root, args.ticket_dir, set(args.active_status)) + print(json.dumps(asdict(result), sort_keys=True)) + return 0 if result.active else 1 + if args.command == "validate": + policy = load_policy(args.root) + path = registry_path(args.root, policy) + if path.exists(): + _validate_registry(_load(path), repository_ref(args.root)) + print(json.dumps({"status": "valid", "registry": str(path), "present": path.exists()})) + return 0 + receipt = _load(args.receipt) + path = record(args.root.resolve(), receipt) + print(json.dumps({"status": "recorded", "registry": str(path), "receiptRef": receipt["receiptRef"]})) + return 0 + except ActivityError as error: + print(f"{error.code}: {error}", file=sys.stderr) + print(" remediation: reconcile the clone-external registry from protected evidence; see error/GOV-TICKET-ACTIVITY.md", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/ticket_allocation.py b/.governance/ticket_allocation.py new file mode 100755 index 0000000..5a04a76 --- /dev/null +++ b/.governance/ticket_allocation.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Build and verify registered ticket allocation requests and receipts.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import re +import sys +from pathlib import Path +from urllib.parse import urlparse + + +CONFIG_SCHEMA = "new-project.ticket-allocation/v1" +REQUEST_SCHEMA = "new-project.ticket-allocation-request/v1" +RECEIPT_SCHEMA = "new-project.ticket-allocation-receipt/v1" +INVALID_RECEIPT_CODE = "GOV-TICKET-ALLOCATION-003" +VISIBLE_CLAIM_CODE = "GOV-TICKET-ALLOCATION-004" +SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") +KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$") +AGENT = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +WORKSTREAM = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + +class AllocationError(ValueError): + pass + + +def load_json(path: Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise AllocationError(f"cannot read JSON {path}: {exc}") from exc + if not isinstance(value, dict): + raise AllocationError(f"expected a JSON object: {path}") + return value + + +def require_exact(value: dict, fields: set[str], label: str) -> None: + if set(value) != fields: + raise AllocationError(f"{label} fields do not match the closed contract") + + +def require_uri(value: object, label: str) -> str: + if not isinstance(value, str) or any(ch.isspace() for ch in value): + raise AllocationError(f"{label} must be an absolute URI") + parsed = urlparse(value) + if not parsed.scheme or not (parsed.netloc or parsed.path): + raise AllocationError(f"{label} must be an absolute URI") + return value + + +def parse_config(path: Path) -> dict: + value = load_json(path) + mode = value.get("mode") + if mode == "local-single-clone": + require_exact(value, {"$schema", "schema", "mode"}, "allocation config") + elif mode == "registered": + require_exact(value, {"$schema", "schema", "mode", "allocator"}, "allocation config") + allocator = value.get("allocator") + if not isinstance(allocator, dict): + raise AllocationError("registered allocator config must be an object") + require_exact(allocator, {"processUri", "issuer", "maxReceiptAgeSeconds"}, "allocator") + require_uri(allocator.get("processUri"), "allocator.processUri") + require_uri(allocator.get("issuer"), "allocator.issuer") + age = allocator.get("maxReceiptAgeSeconds") + if not isinstance(age, int) or isinstance(age, bool) or not 30 <= age <= 3600: + raise AllocationError("allocator.maxReceiptAgeSeconds must be between 30 and 3600") + else: + raise AllocationError("allocation config mode must be local-single-clone or registered") + if value.get("schema") != CONFIG_SCHEMA: + raise AllocationError("unsupported allocation config schema") + return value + + +def canonical_repository_ref(url: str) -> str: + if not isinstance(url, str) or not url or any(ch.isspace() for ch in url): + raise AllocationError("origin URL must be a non-empty URL without whitespace") + match = re.fullmatch(r"git@([^:]+):(.+?)(?:\.git)?", url) + if match: + host, path = match.groups() + url = f"ssh://git@{host}/{path}" + try: + parsed = urlparse(url) + host, port = parsed.hostname, parsed.port + except ValueError: + raise AllocationError("origin URL has an invalid authority") from None + if parsed.scheme and host: + if parsed.password is not None or parsed.query or parsed.fragment: + raise AllocationError("origin URL contains credentials or an ambiguous suffix") + path = parsed.path.lstrip("/").removesuffix(".git") + if not path: + raise AllocationError("origin URL must identify a repository path") + scheme = "git+ssh" if parsed.scheme.lower() in {"ssh", "git+ssh"} else parsed.scheme.lower() + if scheme == "git+ssh" and not parsed.username: + raise AllocationError("SSH origin URL requires an explicit username") + authority = f"[{host.lower()}]" if ':' in host else host.lower() + if scheme == "git+ssh" and parsed.username != "git": + authority = f"{parsed.username}@{authority}" + default_port = {"git+ssh": 22, "https": 443, "http": 80, "git": 9418}.get(scheme) + if port is not None and port != default_port: + authority += f":{port}" + return f"{scheme}://{authority}/{path}" + raise AllocationError("origin URL cannot be normalized to a repository URI") + + +def request_value(args: argparse.Namespace, config: dict) -> dict: + if config["mode"] != "registered": + raise AllocationError("allocation requests are valid only in registered mode") + if not KEY.fullmatch(args.allocation_key): + raise AllocationError("allocation key has an invalid format") + if not AGENT.fullmatch(args.agent): + raise AllocationError("agent has an invalid format") + if not WORKSTREAM.fullmatch(args.workstream): + raise AllocationError("workstream has an invalid format") + if args.kind not in {"BUG", "FEATURE", "SERVICE"}: + raise AllocationError("kind is outside the closed vocabulary") + if args.priority not in {"P0", "P1", "P2", "P3"}: + raise AllocationError("priority is outside the closed vocabulary") + if args.origin not in {"regression", "requested", "health"}: + raise AllocationError("origin is outside the closed vocabulary") + require_uri(args.repository_ref, "repositoryRef") + return { + "schema": REQUEST_SCHEMA, + "repositoryRef": args.repository_ref, + "allocationKey": args.allocation_key, + "titleDigest": "sha256:" + hashlib.sha256(args.title.encode("utf-8")).hexdigest(), + "agent": args.agent, + "workstream": args.workstream, + "classification": { + "kind": args.kind, + "priority": args.priority, + "origin": args.origin, + }, + "processUri": config["allocator"]["processUri"], + } + + +def canonical_digest(value: dict) -> str: + payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def parse_time(value: object, label: str) -> dt.datetime: + if not isinstance(value, str): + raise AllocationError(f"{label} must be an RFC3339 timestamp") + try: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise AllocationError(f"{label} must be an RFC3339 timestamp") from exc + if parsed.tzinfo is None: + raise AllocationError(f"{label} must include a timezone") + return parsed.astimezone(dt.timezone.utc) + + +def validate_receipt_lifetime(receipt: dict, allocator: dict, now: dt.datetime) -> None: + issued = parse_time(receipt.get("issuedAt"), "issuedAt") + expires = parse_time(receipt.get("expiresAt"), "expiresAt") + if issued > now + dt.timedelta(seconds=60): + raise AllocationError("receipt issuedAt is in the future") + if expires <= now: + raise AllocationError("allocation receipt has expired") + if expires <= issued or (expires - issued).total_seconds() > allocator["maxReceiptAgeSeconds"]: + raise AllocationError("allocation receipt lifetime exceeds policy") + + +def validate_receipt(receipt: dict, request: dict, config: dict, now: dt.datetime) -> str: + fields = { + "schema", "allocationId", "repositoryRef", "ticket", "number", + "requestDigest", "processUri", "issuer", "fencingToken", "issuedAt", + "expiresAt", "receiptRef", "proofDigest", + } + require_exact(receipt, fields, "allocation receipt") + if receipt.get("schema") != RECEIPT_SCHEMA: + raise AllocationError("unsupported allocation receipt schema") + for field in ("allocationId", "repositoryRef", "processUri", "issuer", "receiptRef"): + require_uri(receipt.get(field), field) + if receipt["repositoryRef"] != request["repositoryRef"]: + raise AllocationError("receipt repository does not match the request") + allocator = config["allocator"] + if receipt["processUri"] != allocator["processUri"] or receipt["issuer"] != allocator["issuer"]: + raise AllocationError("receipt is not issued by the registered allocator") + if receipt.get("requestDigest") != canonical_digest(request): + raise AllocationError("receipt request digest does not match this invocation") + number = receipt.get("number") + token = receipt.get("fencingToken") + if not isinstance(number, int) or isinstance(number, bool) or number < 1: + raise AllocationError("receipt number must be a positive integer") + if receipt.get("ticket") != f"ticket-{number:03d}": + raise AllocationError("receipt ticket does not match its number") + if not isinstance(token, int) or isinstance(token, bool) or token < 1: + raise AllocationError("receipt fencing token must be a positive integer") + if not SHA256.fullmatch(str(receipt.get("proofDigest", ""))): + raise AllocationError("receipt proof digest is invalid") + validate_receipt_lifetime(receipt, allocator, now) + return f"{number:03d}" + + +def add_request_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--repository-ref", required=True) + parser.add_argument("--allocation-key", required=True) + parser.add_argument("--title", required=True) + parser.add_argument("--agent", required=True) + parser.add_argument("--workstream", required=True) + parser.add_argument("--kind", required=True) + parser.add_argument("--priority", required=True) + parser.add_argument("--origin", required=True) + + +def main() -> int: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + mode = sub.add_parser("mode") + mode.add_argument("--config", type=Path, required=True) + repository = sub.add_parser("repository-ref") + repository.add_argument("--url", required=True) + request = sub.add_parser("request") + add_request_args(request) + validate = sub.add_parser("validate") + add_request_args(validate) + validate.add_argument("--receipt", type=Path, required=True) + args = parser.parse_args() + try: + if args.command == "repository-ref": + print(canonical_repository_ref(args.url)) + return 0 + config = parse_config(args.config) + if args.command == "mode": + print(config["mode"]) + return 0 + request_doc = request_value(args, config) + if args.command == "request": + json.dump(request_doc, sys.stdout, indent=2, ensure_ascii=False) + print() + return 0 + receipt = load_json(args.receipt) + now = dt.datetime.now(dt.timezone.utc) + print(validate_receipt(receipt, request_doc, config, now)) + return 0 + except AllocationError as exc: + print(f"{INVALID_RECEIPT_CODE}: {exc}", file=sys.stderr) + return 5 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/ticket_index_merge_driver.py b/.governance/ticket_index_merge_driver.py new file mode 100755 index 0000000..9011e16 --- /dev/null +++ b/.governance/ticket_index_merge_driver.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Custom Git merge driver for Wellmanifest project/TICKETS.md and TODO.md tables. + +Merge unambiguous AUTO:TICKET_INDEX changes against the ancestor. Preserve +deletions and surrounding prose; conflicting or ambiguous input falls back to Git. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +START_MARKER = "" +END_MARKER = "" +ROW_PATTERN = re.compile(r"^[ \t]*\|[ \t]*\*\*ticket-([0-9]+)\*\*[ \t]*\|") + + +def extract_table_rows(content: str) -> tuple[str, list[str], str] | None: + if content.count(START_MARKER) != 1 or content.count(END_MARKER) != 1: + return None + start_idx = content.find(START_MARKER) + end_idx = content.find(END_MARKER) + if start_idx == -1 or end_idx == -1 or start_idx >= end_idx: + return None + + header_part = content[: start_idx + len(START_MARKER)] + footer_part = content[end_idx:] + middle = content[start_idx + len(START_MARKER) : end_idx] + + lines = middle.strip("\n").split("\n") if middle.strip("\n") else [] + return header_part, lines, footer_part + + +def parse_ticket_rows(lines: list[str]) -> tuple[list[str], dict[int, str]]: + table_headers: list[str] = [] + ticket_rows: dict[int, str] = {} + + for line in lines: + stripped = line.strip() + if not stripped: + continue + match = ROW_PATTERN.match(stripped) + if match: + ticket_id = int(match.group(1)) + ticket_rows[ticket_id] = stripped + elif stripped.startswith("|") and ( + "Ticket ID" in stripped or ":---" in stripped or ":-" in stripped + ): + table_headers.append(stripped) + + return table_headers, ticket_rows + + +def checked_table(content: str): + parts = extract_table_rows(content) + if parts is None: + return None + prefix, lines, suffix = parts + headers, rows = parse_ticket_rows(lines) + row_lines = [line.strip() for line in lines if ROW_PATTERN.match(line.strip())] + if len(row_lines) != len(rows): + return None # Duplicate identities must not silently overwrite a row. + if any(line.strip() and line.strip() not in headers and not ROW_PATTERN.match(line.strip()) + for line in lines): + return None # The custom driver must not drop unknown target-owned text. + return prefix, headers, rows, suffix + + +def three_way(ancestor, current, other): + if current == other: + return True, current + if current == ancestor: + return True, other + if other == ancestor: + return True, current + return False, None + + +def merge_ticket_index_content(ancestor_text: str, current_text: str, other_text: str) -> str | None: + parts = [checked_table(text) for text in (ancestor_text, current_text, other_text)] + if any(part is None for part in parts): + return None + ancestor, current, other = parts + merged = [] + for position in (0, 1, 3): + clean, value = three_way(ancestor[position], current[position], other[position]) + if not clean: + return None + merged.append(value) + ancestor_rows, current_rows, other_rows = ancestor[2], current[2], other[2] + rows = [] + for ticket in sorted(set(ancestor_rows) | set(current_rows) | set(other_rows)): + clean, value = three_way(ancestor_rows.get(ticket), current_rows.get(ticket), other_rows.get(ticket)) + if not clean: + return None + if value is not None: + rows.append(value) + prefix, headers, suffix = merged + return prefix + "\n" + "\n".join(headers + rows) + "\n" + suffix + + +def run_merge(ancestor_file: Path, current_file: Path, other_file: Path) -> int: + try: + current_text = current_file.read_text(encoding="utf-8") + other_text = other_file.read_text(encoding="utf-8") + ancestor_text = ancestor_file.read_text(encoding="utf-8") if ancestor_file.is_file() else "" + except Exception: + return subprocess.run( + ["git", "merge-file", str(current_file), str(ancestor_file), str(other_file)] + ).returncode + + merged_text = merge_ticket_index_content(ancestor_text, current_text, other_text) + if merged_text is not None: + current_file.write_text(merged_text, encoding="utf-8") + return 0 + + return subprocess.run( + ["git", "merge-file", str(current_file), str(ancestor_file), str(other_file)] + ).returncode + + +def self_test() -> int: + ancestor = ( + "# Ticket index\n\n" + "\n" + "| Ticket ID | Spec |\n" + "| :--- | :--- |\n" + "| **ticket-100** | [`README.md`](./ticket-100/README.md) |\n" + "\n" + ) + current = ( + "# Ticket index\n\n" + "\n" + "| Ticket ID | Spec |\n" + "| :--- | :--- |\n" + "| **ticket-100** | [`README.md`](./ticket-100/README.md) |\n" + "| **ticket-136** | [`README.md`](./ticket-136/README.md) |\n" + "\n" + ) + other = ( + "# Ticket index\n\n" + "\n" + "| Ticket ID | Spec |\n" + "| :--- | :--- |\n" + "| **ticket-100** | [`README.md`](./ticket-100/README.md) |\n" + "| **ticket-127** | [`README.md`](./ticket-127/README.md) |\n" + "\n" + ) + merged = merge_ticket_index_content(ancestor, current, other) + assert merged is not None + assert "| **ticket-100** |" in merged + assert "| **ticket-127** |" in merged + assert "| **ticket-136** |" in merged + pos_100 = merged.find("**ticket-100**") + pos_127 = merged.find("**ticket-127**") + pos_136 = merged.find("**ticket-136**") + assert pos_100 < pos_127 < pos_136, f"Order error: {merged}" + print("Self-test passed: ticket-100 < ticket-127 < ticket-136 sorted perfectly without conflict.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + if "--self-test" in args: + return self_test() + if len(args) < 3: + print( + "Usage: ticket_index_merge_driver.py ", + file=sys.stderr, + ) + return 2 + return run_merge(Path(args[0]), Path(args[1]), Path(args[2])) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.governance/ticket_input.py b/.governance/ticket_input.py new file mode 100644 index 0000000..ff63919 --- /dev/null +++ b/.governance/ticket_input.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Read Registry ticket content without creating repository carrier files. + +Local SQLite is advisory input. Protected callers must provide an independently +acquired snapshot and digest, bound to the exact repository/base/head. +""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import sqlite3 +import subprocess +import sys + +MAX_BYTES = 32 * 1024 * 1024 +APPLICATION_ID = 1398030897 +SCHEMA = "new-project.ticket-input/v1" + + +class TicketInputError(ValueError): + pass + + +def git(root, *args): + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env.update(GIT_OPTIONAL_LOCKS="0", LC_ALL="C") + return subprocess.check_output(["git", "-C", str(root), *args], env=env, stderr=subprocess.PIPE) + + +def no_links(path): + for candidate in (path, *path.parents): + if candidate.is_symlink(): + raise TicketInputError("ticket input symlink rejected") + if candidate.is_file() and candidate.stat().st_nlink != 1: + raise TicketInputError("ticket input hardlink rejected") + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise TicketInputError("duplicate JSON key") + result[key] = value + return result + + +def parse_json(data): + return json.loads(data, object_pairs_hook=unique_object) + + +def validate_document_contract(doc, ticket): + if (not isinstance(doc, dict) or set(doc) != {"schema", "ticket", "files", "execution_authorized", "merge_authorized"} + or doc["schema"] != "registry.ticket-content/v1" or doc["ticket"] != ticket + or doc["execution_authorized"] is not False or doc["merge_authorized"] is not False + or not isinstance(doc["files"], dict)): + raise TicketInputError("invalid ticket content contract") + + +def validate_ticket_path(name): + if (not isinstance(name, str) or not name or len(name) > 1024 or "\\" in name + or any(part in {"", ".", ".."} for part in name.split("/")) + or re.search(r"[\x00-\x1f\x7f]", name)): + raise TicketInputError("invalid ticket file path") + + +def decode_ticket_file(name, entry): + validate_ticket_path(name) + if (not isinstance(entry, dict) or set(entry) != {"encoding", "mode", "sha256", "content"} + or entry["encoding"] != "base64" or entry["mode"] not in {"100644", "100755"} + or not isinstance(entry["content"], str)): + raise TicketInputError("invalid ticket file record") + content = base64.b64decode(entry["content"], validate=True) + if (base64.b64encode(content).decode("ascii") != entry["content"] + or hashlib.sha256(content).hexdigest() != entry["sha256"]): + raise TicketInputError("ticket file digest mismatch") + return content, entry["mode"] + + +def decode_document(ticket, revision, sha, raw): + if not isinstance(ticket, str) or re.fullmatch(r"ticket-[0-9]{3,}", ticket) is None: + raise TicketInputError("unsupported ticket identity") + if type(revision) is not int or revision < 1 or not isinstance(raw, str): + raise TicketInputError("invalid ticket revision") + if hashlib.sha256(raw.encode("utf-8")).hexdigest() != sha: + raise TicketInputError("ticket document digest mismatch") + doc = parse_json(raw) + validate_document_contract(doc, ticket) + files = {name: decode_ticket_file(name, entry) for name, entry in doc["files"].items()} + return {"ticket": ticket, "revision": revision, "files": files} + + +def primary_database(root): + records = git(root, "worktree", "list", "--porcelain", "-z").decode().split("\0") + if not records[0].startswith("worktree ") or "bare" in records: + raise TicketInputError("registered primary checkout required") + database = Path(records[0][9:]) / "project.sqlite" + no_links(database) + return database + + +def configured_mode(root): + try: + mode = git(root, "config", "--local", "--get", "new-project.ticketStorage").decode().strip() + except subprocess.CalledProcessError as error: + if error.returncode != 1: + raise + mode = "files" + if mode not in {"files", "sqlite"}: + raise TicketInputError("unknown configured ticket storage") + return mode + + +def configured_records(root): + if configured_mode(root) == "files": + return None + return [decode_document(*row) for row in database_rows(root, primary_database(root))] + + +def read_file(root, ticket, filename): + for item in database_rows(root, primary_database(root)): + if item[0] == ticket: + files = decode_document(*item)["files"] + if filename in files: + return files[filename][0] + raise TicketInputError("ticket content not found") + + +def database_rows(root, database): + database = Path(database).absolute() + no_links(database) + primary = primary_database(root).parent + if database != primary / "project.sqlite": + raise TicketInputError("database must be primary checkout project.sqlite") + names = ["project.sqlite" + suffix for suffix in ("", "-wal", "-shm", "-journal")] + for name in names: + no_links(primary / name) + for checkout in {Path(root).absolute(), primary}: + if git(checkout, "ls-files", "-z", "--", *names): + raise TicketInputError("ticket database is tracked") + ignored = git(checkout, "check-ignore", "--", *names).decode().splitlines() + if set(ignored) != set(names): + raise TicketInputError("ticket database and sidecars must be ignored") + if not database.is_file() or database.stat().st_mode & 0o077: + raise TicketInputError("private initialized database required") + connection = sqlite3.connect(database.as_uri() + "?mode=ro", uri=True) + try: + connection.execute("PRAGMA query_only=ON") + connection.execute("PRAGMA trusted_schema=OFF") + if (connection.execute("PRAGMA application_id").fetchone()[0] != APPLICATION_ID + or connection.execute("PRAGMA user_version").fetchone()[0] != 1): + raise TicketInputError("unsupported Registry database schema") + rows, total = [], 0 + cursor = connection.execute("""SELECT ticket, revision, document_sha256, document_json + FROM ticket_versions AS t WHERE revision=(SELECT MAX(revision) + FROM ticket_versions WHERE ticket=t.ticket) ORDER BY ticket""") + for row in cursor: + total += len(row[3].encode("utf-8")) + if total > MAX_BYTES or len(rows) >= 10000: + raise TicketInputError("ticket input exceeds bound") + decode_document(*row) + rows.append(row) + return rows + finally: + connection.close() + + +def subject(root, repository, base, head): + if not isinstance(repository, str) or re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) is None: + raise TicketInputError("explicit repository identity required") + if not base: + raise TicketInputError("explicit validation base required") + resolved = [git(root, "rev-parse", "--verify", "--end-of-options", ref + "^{commit}").decode().strip() + for ref in (base, head)] + return {"repository": repository, "base_sha": resolved[0], "head_sha": resolved[1]} + + +def export_snapshot(root, database, repository, base, head): + binding = subject(root, repository, base, head) + return {"schema": SCHEMA, **binding, "tickets": [ + {"ticket": row[0], "revision": row[1], "document_sha256": row[2], "document_json": row[3]} + for row in database_rows(root, database)], "execution_authorized": False, "merge_authorized": False} + + +def read_pinned_snapshot(snapshot, snapshot_sha256): + if not isinstance(snapshot_sha256, str) or re.fullmatch(r"[a-f0-9]{64}", snapshot_sha256) is None: + raise TicketInputError("independent snapshot SHA-256 required") + path = Path(snapshot).absolute() + no_links(path) + if not path.is_file() or path.stat().st_size > MAX_BYTES: + raise TicketInputError("bounded snapshot file required") + try: + git(path.parent, "rev-parse", "--absolute-git-dir") + except subprocess.CalledProcessError as error: + if error.returncode != 128 or b"not a git repository" not in error.stderr: + raise + else: + raise TicketInputError("snapshot must be outside Git checkouts") + raw = path.read_bytes() + if hashlib.sha256(raw).hexdigest() != snapshot_sha256: + raise TicketInputError("snapshot digest mismatch") + return raw + + +def decode_snapshot(doc, binding): + if (not isinstance(doc, dict) or set(doc) != {"schema", *binding, "tickets", "execution_authorized", "merge_authorized"} + or doc["schema"] != SCHEMA or any(doc[key] != value for key, value in binding.items()) + or doc["execution_authorized"] is not False or doc["merge_authorized"] is not False + or not isinstance(doc["tickets"], list) or len(doc["tickets"]) > 10000): + raise TicketInputError("snapshot subject or contract mismatch") + result, identities = [], set() + for item in doc["tickets"]: + if not isinstance(item, dict) or set(item) != {"ticket", "revision", "document_sha256", "document_json"}: + raise TicketInputError("invalid snapshot ticket entry") + decoded = decode_document(item["ticket"], item["revision"], item["document_sha256"], item["document_json"]) + if decoded["ticket"] in identities: + raise TicketInputError("duplicate snapshot ticket") + identities.add(decoded["ticket"]) + result.append(decoded) + return result + + +def load_input(root, *, database=None, snapshot=None, snapshot_sha256=None, + repository=None, base=None, head="HEAD", protected=False): + if database and (snapshot or snapshot_sha256): + raise TicketInputError("select exactly one ticket input") + if database: + if protected: + raise TicketInputError("protected validation requires an independently pinned snapshot") + return [decode_document(*row) for row in database_rows(root, database)] + if not snapshot: + if snapshot_sha256: + raise TicketInputError("snapshot path required") + return None + raw = read_pinned_snapshot(snapshot, snapshot_sha256) + doc = parse_json(raw) + binding = subject(root, repository, base, head) + return decode_snapshot(doc, binding) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["export", "read"]) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--database") + parser.add_argument("--repository") + parser.add_argument("--base") + parser.add_argument("--head", default="HEAD") + parser.add_argument("--ticket") + parser.add_argument("--file", choices=["README.md", "intent.json"]) + args = parser.parse_args() + try: + if args.command == "read": + sys.stdout.buffer.write(read_file(args.root, args.ticket, args.file)) + else: + print(json.dumps(export_snapshot(args.root, args.database or primary_database(args.root), args.repository, args.base, args.head), sort_keys=True)) + except (ValueError, OSError, sqlite3.Error, subprocess.SubprocessError): + parser.exit(1, "ticket input operation failed; no content emitted\n") diff --git a/.governance/ticket_recovery.py b/.governance/ticket_recovery.py new file mode 100644 index 0000000..ebd8156 --- /dev/null +++ b/.governance/ticket_recovery.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Bounded local allocator recovery; never a protected approval mechanism. + +The trusted local caller supplies the existing external controller store. Its +lock is held throughout observation and materialization. We neither create a +controller store nor acquire/renew/reclaim leases. Multi-node and SQLite ticket +allocation must use their own registered recovery controller instead. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import re +import stat +import sys +from urllib.parse import urlparse + +sys.dont_write_bytecode = True +from governance_check import pattern_covered_by, validate_intent_value +import work_start_check as start +from ticket_allocation import canonical_repository_ref + +FIELDS = {"schema", "intent", "headSha", "dirtyDigest", "leaseId", + "leaseRevision", "fencingToken", "ownerActor", "ownerSession"} + + +class RecoveryError(ValueError): + """Safe fixed diagnostic; candidate/controller data is never interpolated.""" + + +def require(condition, message): + if not condition: + raise RecoveryError(message) + + +def safe_path(path): + path = Path(os.path.abspath(path)) + require(all(not p.is_symlink() for p in (path, *path.parents)), "Symlinked recovery path") + return path + + +def load(path): + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate JSON key") + result[key] = value + return result + descriptor = os.open(safe_path(path), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as stream: + require(stat.S_ISREG(os.fstat(stream.fileno()).st_mode), "Regular file required") + raw = stream.read(4 * 1024 * 1024 + 1) + require(len(raw) <= 4 * 1024 * 1024, "Recovery input too large") + return json.loads(raw, object_pairs_hook=unique) + + +@contextmanager +def controller_lock(store): + # POSIX controller adapter, fail closed on platforms without flock. + import fcntl + path = safe_path(store / ".lock") + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + require(stat.S_ISREG(os.fstat(descriptor).st_mode), "Controller lock must be a regular file") + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + yield + finally: + os.close(descriptor) + + +def validate_lease(store, request, intent, repository, branch, worktree): + state = load(store / "state.json") + require(state.get("schema") == "subactor.repository-change-lease-store/v1", "Unsupported controller store") + lease = state["leases"][request["leaseId"]] + expected = { + "schema": "wellmanifest.change-lease/v1", "leaseId": request["leaseId"], + "leaseRevision": request["leaseRevision"], "fencingToken": request["fencingToken"], + "ownerActor": request["ownerActor"], "ownerSession": request["ownerSession"], + "repositoryRef": repository, "ticketId": intent["ticket"], + "targetBranch": intent["delivery"]["targetBranch"], "branchRef": branch, + "workstream": intent["workstream"], "worktreeId": worktree, + "scopeHash": start.digest(intent["allowedPaths"]), "phase": "editing", + "publicationFrozen": False, + } + require(all(lease.get(k) == v for k, v in expected.items()), "Controller owner, scope or CAS mismatch") + resource = start.digest({"repositoryRef": repository, "targetBranch": expected["targetBranch"]}) + require(state["active"].get(resource) == lease["leaseId"], "Lease is not the active controller owner") + expires = datetime.fromisoformat(lease["expiresAt"].replace("Z", "+00:00")) + require(expires.tzinfo is not None and expires > datetime.now(timezone.utc), "Expired lease; explicit controller handoff required") + return lease + + +def inspect_recovery(root, request, workstream): + require(isinstance(request, dict) and set(request) == FIELDS + and request["schema"] == "new-project.ticket-recovery-request/v1", "Invalid recovery request") + for field in ("headSha", "dirtyDigest"): + require(isinstance(request[field], str) and re.fullmatch( + r"[0-9a-f]{40}" if field == "headSha" else r"[0-9a-f]{64}", request[field]), "Invalid exact-state binding") + for field in ("leaseRevision", "fencingToken"): + require(type(request[field]) is int and request[field] > 0, "Positive integer CAS required") + for field in ("leaseId", "ownerActor", "ownerSession"): + require(isinstance(request[field], str) and 0 < len(request[field]) <= 255, "Explicit owner required") + intent = request["intent"] + require(isinstance(intent, dict), "Bounded intent required") + _, error = validate_intent_value(intent, intent.get("ticket", "")) + require(error is None, "Invalid bounded intent") + require(intent.get("schema") == "new-project.intent/v3" and "delivery" in intent, + "Complete v3 delivery intent required") + require(not any(ord(c) < 32 for c in intent["summary"]), "Single-line ticket summary required") + require(intent["workstream"] == workstream, "Workstream mismatch") + ticket = intent["ticket"] + require(re.fullmatch(r"ticket-[0-9]{3,}", ticket), "Invalid ticket identity") + branch = start.git(root, "symbolic-ref", "HEAD").strip() + match = re.fullmatch(r"refs/heads/ticket/" + ticket[7:] + r"-([a-z0-9]+(?:-[a-z0-9]+)*)", branch) + require(match is not None, "Recovery requires the existing matching ticket branch") + registrations = start.worktrees(root) + primary = safe_path(registrations[0]["worktree"]) + require(root == primary / ".worktrees" / (ticket + "--" + match[1]), "Noncanonical recovery checkout") + gitdir = Path(start.git(root, "rev-parse", "--absolute-git-dir").strip()) + backlink = (gitdir / "gitdir").read_text().strip() + require(not Path(backlink).is_absolute(), "Relative worktree registration required") + require(not (root / "project" / ticket).exists(), "Existing ticket must be continued, not imported") + require(start.git(root, "rev-parse", "HEAD").strip() == request["headSha"], "HEAD changed") + report = start.inspect(root, workstream, ticket=ticket, storage="files", + expected_dirty_digest=request["dirtyDigest"], recovery_intent=intent) + require(report["route"] == "REUSE_EXISTING" and report["activeTicketCount"] <= report["workstreamLimit"], + "Recovery admission contested or WIP exceeded") + selected = next(e for e in report["worktrees"] if e["path"] == str(root)) + require(selected["active"], "Terminal identity cannot be recovered as active work") + require(selected["dirtyDigest"] == request["dirtyDigest"], "Dirty state changed") + base = intent["delivery"]["acceptedBaseSha"] + require(start.git(root, "merge-base", base, request["headSha"]).strip() == base, "Accepted base is not an ancestor") + paths = set(start.git(root, "diff", "--name-only", "--no-renames", "-z", base, request["headSha"]).split("\0")) - {""} + paths.update(selected["allDirtyPaths"]) + allowed = tuple(start.patterns(intent["allowedPaths"])) + forbidden = tuple(intent["forbiddenPaths"]) + owned = tuple(start.manifest_at(root)["coordination"]["workstreams"][workstream]["ownedPaths"]) + require(all(any(pattern_covered_by(pattern, owner) for owner in owned) + for pattern in start.material(list(allowed))), + "Declared recovery scope exceeds workstream ownership") + require(all(start.path_ignored(p, owned) for p in start.material(list(paths))), + "Existing work exceeds workstream ownership") + require(all(start.path_ignored(p, allowed) and not start.path_ignored(p, forbidden) for p in paths), + "Existing work exceeds accepted recovery scope") + own = f"project/{ticket}/" + require(all(start.path_ignored(own + name, allowed) and not start.path_ignored(own + name, forbidden) + for name in ("README.md", "intent.json")), "Recovery carriers outside scope") + # A branch spelling is not a ticket reservation. Never duplicate an identity + # found in another checkout, ref, SQLite store, or the clone high-water mark. + require(not (primary / "project.sqlite").exists(), "SQLite identity requires its registered adapter") + for entry in registrations: + require(not (safe_path(entry["worktree"]) / "project" / ticket).exists(), "Ticket already exists in a checkout") + refs = start.git(root, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes").splitlines() + origin_alias = "refs/remotes/origin/" + branch.removeprefix("refs/heads/") + for ref in refs: + require(not start.git(root, "ls-tree", "--name-only", ref, own.rstrip("/")).strip(), "Ticket already exists in Git history") + name = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref.split("/", 3)[-1] + other = start.TICKET.fullmatch(name) + same_published_branch = (ref == origin_alias + and start.git(root, "rev-parse", ref).strip() == request["headSha"]) + require(not other or other[1] != ticket[7:] or ref == branch or same_published_branch, + "Ticket branch collision") + require(report["targetBranch"] == intent["delivery"]["targetBranch"], "Target branch mismatch") + return intent, report, primary, branch + + +def recover(root, request_path, store, workstream): + require(os.name == "posix" and hasattr(os, "O_NOFOLLOW"), "POSIX local controller adapter required") + root, store, request_path = map(safe_path, (root, store, request_path)) + root = safe_path(start.git(root, "rev-parse", "--show-toplevel").strip()) + common = safe_path(start.git(root, "rev-parse", "--path-format=absolute", "--git-common-dir").strip()) + require(start.configured_mode(root) == "files", "Recovery only supports file tickets") + policies = [root / ".governance/ticket-allocation.json", root / "governance/ticket-allocation.json"] + policy = next((p for p in policies if p.exists()), None) + if policy: + from ticket_allocation import parse_config + require(parse_config(policy)["mode"] == "local-single-clone", "Registered allocation requires its controller adapter") + for location in start.worktrees(root): + checkout = safe_path(location["worktree"]) + require(not store.is_relative_to(checkout) and not request_path.is_relative_to(checkout), + "Controller and request must be outside all candidate checkouts") + require(not store.is_relative_to(common), "Controller cannot be stored in candidate Git metadata") + lock = safe_path(common / "new-project-ticket-allocation.lock") + lock.mkdir() # same non-stealable clone lock as ordinary allocation + try: + with controller_lock(store): + request = load(request_path) + intent, report, primary, branch = inspect_recovery(root, request, workstream) + origin = urlparse(canonical_repository_ref(start.git(root, "config", "--get", "remote.origin.url").strip())) + require(origin.hostname == "github.com" and origin.scheme in {"https", "git+ssh"}, + "This controller adapter requires a canonical GitHub repository") + repository = origin.path.lstrip("/") + lease = validate_lease(store, request, intent, repository, branch, root.name) + number = int(intent["ticket"][7:]) + highwater = safe_path(common / "new-project-ticket-high-water") + previous = highwater.read_text().strip() if highwater.exists() else "0" + require(re.fullmatch(r"[0-9]+", previous) and number > int(previous), "Identity already reserved; preserve and reconcile") + # Observe again while controller ownership cannot transition. No Git + # state, lease or existing source file is ever rewritten by recovery. + _, refreshed, _, _ = inspect_recovery(root, request, workstream) + require(refreshed["observationDigest"] == report["observationDigest"], "Workspace observation changed") + validate_lease(store, request, intent, repository, branch, root.name) + temporary = common / "new-project-ticket-high-water.recovery" + with temporary.open("x") as stream: + stream.write(str(number) + "\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, highwater) + directory = safe_path(root / "project" / intent["ticket"]) + directory.mkdir() # crash/partial recovery remains reserved, never overwritten + readme = (f"# {intent['ticket']}: {intent['summary']}\n\n" + "- **Status**: IN_PROGRESS\n- **Workflow state**: EDIT\n\n" + "## Recovery boundary\n\n" + "Existing work registered through the fenced local allocator.\n" + "This is not retrospective validation or protected merge approval.\n") + criteria = sorted({item["criterion"] for item in intent["delivery"]["validation"]}) + readme += "\n## Acceptance criteria\n\n" + "".join( + f"- [ ] {criterion}: Validate the corresponding accepted intent criterion.\n" for criterion in criteria) + for name, content in (("README.md", readme), ("intent.json", json.dumps(intent, indent=2) + "\n")): + with (directory / name).open("x") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + return {"schema": "new-project.ticket-recovery-result/v1", "ticket": intent["ticket"], + "headSha": request["headSha"], "leaseId": lease["leaseId"], + "leaseRevision": lease["leaseRevision"], "fencingToken": lease["fencingToken"], + "requestDigest": start.digest(request), "grantsMergeAuthority": False} + finally: + lock.rmdir() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--request", type=Path, required=True) + parser.add_argument("--lease-store", type=Path, required=True) + parser.add_argument("--workstream", required=True) + args = parser.parse_args() + try: + print(json.dumps(recover(args.root, args.request, args.lease_store, args.workstream))) + return 0 + except RecoveryError as error: + print("GOV-TICKET-ALLOCATION-003: " + str(error) + "; preserve work and re-observe before retry.", file=sys.stderr) + return 5 + except (OSError, ValueError, KeyError, TypeError, ImportError): + # No controller contents or candidate paths in the shared diagnostic. + print("GOV-TICKET-ALLOCATION-003: recovery rejected; re-observe exact request, live controller ownership, scope and admission; preserve all work.", file=sys.stderr) + return 5 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.governance/ticket_restoration.py b/.governance/ticket_restoration.py new file mode 100644 index 0000000..a76a453 --- /dev/null +++ b/.governance/ticket_restoration.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Restore the checkout of an already reserved ticket; never allocate or approve. + +The allocator's reservation record, high-water mark and the external controller +lease stay authoritative. Restoration re-registers the canonical checkout and +writes only the ticket's own README.md and intent.json from a pinned snapshot. +It grants neither source-editing nor merge authority: normal work-start +admission and independent review remain mandatory afterwards. +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import sys +import tempfile +from urllib.parse import urlparse + +sys.dont_write_bytecode = True +from governance_check import validate_intent_value +import ticket_recovery as recovery +import work_start_check as start +from ticket_allocation import canonical_repository_ref + +require = recovery.require +safe = recovery.safe_path +FIELDS = {"schema", "ticket", "worktreeId", "headSha", "snapshotPath", "snapshotSha256", + "leaseId", "leaseRevision", "fencingToken", "ownerActor", "ownerSession"} +METADATA = ("README.md", "intent.json") +RESTORING_PHASES = {"claimed", "editing"} + + +def validate_request(request, workstream): + require(isinstance(request, dict) and set(request) == FIELDS + and request["schema"] == "new-project.ticket-restoration-request/v1", "Invalid restoration request") + ticket = request["ticket"] + require(isinstance(ticket, str) and re.fullmatch(r"ticket-[0-9]{3,}", ticket), "Invalid ticket identity") + require(isinstance(request["worktreeId"], str) and re.fullmatch( + re.escape(ticket) + r"--[a-z0-9]+(?:-[a-z0-9]+)*", request["worktreeId"]), "Noncanonical worktree identity") + require(request["headSha"] is None or (isinstance(request["headSha"], str) + and re.fullmatch(r"[0-9a-f]{40}", request["headSha"])), "Invalid exact-head binding") + require(isinstance(request["snapshotSha256"], str) + and re.fullmatch(r"[0-9a-f]{64}", request["snapshotSha256"]), "Invalid snapshot digest") + require(isinstance(request["snapshotPath"], str) and Path(request["snapshotPath"]).is_absolute(), + "Absolute snapshot path required") + for field in ("leaseRevision", "fencingToken"): + require(type(request[field]) is int and request[field] > 0, "Positive integer CAS required") + for field in ("leaseId", "ownerActor", "ownerSession"): + require(isinstance(request[field], str) and 0 < len(request[field]) <= 255, "Explicit owner required") + require(isinstance(workstream, str) and workstream, "Workstream required") + + +def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate JSON key") + result[key] = value + return result + + +def load_snapshot(request, workstream): + # Hash and parse the same bytes; the path is never reopened after hashing. + descriptor = os.open(safe(request["snapshotPath"]), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as stream: + require(stat.S_ISREG(os.fstat(stream.fileno()).st_mode), "Regular file required") + raw = stream.read(4 * 1024 * 1024 + 1) + require(len(raw) <= 4 * 1024 * 1024, "Snapshot too large") + require(hashlib.sha256(raw).hexdigest() == request["snapshotSha256"], "Snapshot digest mismatch") + snapshot = json.loads(raw, object_pairs_hook=unique) + require(isinstance(snapshot, dict) and set(snapshot) == {"schema", "ticket", "files"} + and snapshot["schema"] == "new-project.ticket-metadata-snapshot/v1" + and snapshot["ticket"] == request["ticket"], "Invalid metadata snapshot") + files = snapshot["files"] + require(isinstance(files, dict) and set(files) == set(METADATA) + and all(isinstance(v, str) for v in files.values()), "Snapshot must hold exactly README.md and intent.json") + intent = json.loads(files["intent.json"], object_pairs_hook=unique) + _, error = validate_intent_value(intent, request["ticket"]) + require(error is None and intent.get("ticket") == request["ticket"], "Invalid snapshot intent") + require(intent.get("workstream") == workstream, "Workstream mismatch") + require(re.search(r"^- \*\*Status\*\*: IN_PROGRESS$", files["README.md"], re.M), + "Terminal identity cannot be restored as active work") + return files, intent + + +def reservation(primary, common, request, workstream): + number = int(request["ticket"][7:]) + highwater = safe(common / "new-project-ticket-high-water") + previous = highwater.read_text().strip() if highwater.exists() else "0" + require(re.fullmatch(r"[0-9]+", previous) and number <= int(previous), + "Identity is not reserved; allocate normally") + leases = safe(primary / ".subactor" / "leases") + matches = [p for p in sorted(leases.glob("ticket-*.json")) + if p.name.startswith(request["ticket"] + "--")] + require([p.name for p in matches] == [request["worktreeId"] + ".json"], "Missing or ambiguous reservation") + record = recovery.load(matches[0]) + slug = request["worktreeId"][len(request["ticket"]) + 2:] + expected = {"schema": "wellmanifest.change-lease/v1", "ticketId": request["ticket"], + "worktreeId": request["worktreeId"], "workstream": workstream, + "branchRef": f"refs/heads/ticket/{request['ticket'][7:]}-{slug}", + "ownerActor": request["ownerActor"], "ownerSession": request["ownerSession"]} + require(all(record.get(k) == v for k, v in expected.items()), "Reservation identity or owner mismatch") + require(isinstance(record.get("headSha"), str) and re.fullmatch(r"[0-9a-f]{40}", record["headSha"]), + "Reservation base unavailable") + return record + + +def controller(store, request, record, intent, repository): + state = recovery.load(store / "state.json") + require(state.get("schema") == "subactor.repository-change-lease-store/v1", "Unsupported controller store") + lease = state["leases"][request["leaseId"]] + expected = {"schema": "wellmanifest.change-lease/v1", "leaseId": request["leaseId"], + "leaseRevision": request["leaseRevision"], "fencingToken": request["fencingToken"], + "ownerActor": request["ownerActor"], "ownerSession": request["ownerSession"], + "repositoryRef": repository, "ticketId": request["ticket"], + "targetBranch": record["targetBranch"], "branchRef": record["branchRef"], + "workstream": record["workstream"], "worktreeId": request["worktreeId"], + "scopeHash": start.digest(intent["allowedPaths"]), "publicationFrozen": False} + require(all(lease.get(k) == v for k, v in expected.items()) and lease.get("phase") in RESTORING_PHASES, + "Controller owner, scope or CAS mismatch") + resource = start.digest({"repositoryRef": repository, "targetBranch": record["targetBranch"]}) + require(state["active"].get(resource) == lease["leaseId"], "Lease is not the active controller owner") + expires = datetime.fromisoformat(lease["expiresAt"].replace("Z", "+00:00")) + require(expires.tzinfo is not None and expires > datetime.now(timezone.utc), + "Expired lease; explicit controller handoff required") + return lease + + +def observe(primary, request, record, files, intent): + """Return the exact current restoration state; reject every collision.""" + ticket, branch = request["ticket"], record["branchRef"] + canonical = safe(primary / ".worktrees" / request["worktreeId"]) + head = start.git(primary, "rev-parse", "--verify", "--quiet", branch + "^{commit}", optional=True) + head = head.strip() if head else None + target = request["headSha"] or record["headSha"] + require(head in (None, target), "Branch head differs from the bound restoration head") + require(start.git(primary, "cat-file", "-e", target + "^{commit}", optional=True) is not None, + "Bound restoration head is unreachable") + base = record["headSha"] + require(start.git(primary, "merge-base", "--is-ancestor", base, target, optional=True) is not None, + "Restoration head does not descend from the reserved base") + accepted = intent.get("delivery", {}).get("acceptedBaseSha") + require(accepted is None or start.git(primary, "merge-base", "--is-ancestor", accepted, target, + optional=True) is not None, "Accepted base is not an ancestor") + registered = None + for entry in start.worktrees(primary): + path = safe(entry["worktree"]) + if path == canonical: + require("prunable" not in entry, "Stale canonical registration; prune it after confirming the loss") + require(entry.get("branch") == branch, "Canonical checkout registration collides") + registered = path + continue + require(entry.get("branch") != branch, "Ticket branch is checked out elsewhere") + require(not (path / "project" / ticket).exists(), "Ticket already exists in another checkout") + refs = start.git(primary, "for-each-ref", "--format=%(refname:lstrip=2)", "refs/heads").splitlines() + for name in refs: + other = start.TICKET.fullmatch(name) + require(not other or other[1] != ticket[7:] or "refs/heads/" + name == branch, "Ticket branch collision") + require(registered is not None or not os.path.lexists(canonical), "Unregistered path occupies the checkout") + committed = bool(start.git(primary, "ls-tree", "--name-only", target, f"project/{ticket}").strip()) + present = {} + if registered: + backlink = Path(start.git(canonical, "rev-parse", "--absolute-git-dir").strip()) / "gitdir" + require(not Path(backlink.read_text().strip()).is_absolute(), "Relative worktree registration required") + paths = start.dirty_observation(canonical)[2] + require(all(p.startswith(f"project/{ticket}/") for p in paths), "Restored checkout has foreign changes") + directory = canonical / "project" / ticket + require(not directory.is_symlink(), "Symlinked ticket metadata") + for name in METADATA if not committed else (): + path = directory / name + if os.path.lexists(path): + require(not path.is_symlink() and path.read_text() == files[name], + "Existing ticket metadata differs; preserve and continue under normal admission") + present[name] = True + return {"canonical": canonical, "branchHead": head, "target": target, "registered": registered is not None, + "committed": committed, "present": sorted(present)} + + +def write_new(common, path, content): + descriptor, temporary = tempfile.mkstemp(prefix="new-project-ticket-restore-", dir=common) + try: + with os.fdopen(descriptor, "w") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.link(temporary, path) # never replaces an existing file + finally: + os.unlink(temporary) + + +def restore(root, request_path, store, workstream): + require(os.name == "posix" and hasattr(os, "O_NOFOLLOW"), "POSIX local controller adapter required") + root, store, request_path = map(safe, (root, store, request_path)) + common = safe(start.git(root, "rev-parse", "--path-format=absolute", "--git-common-dir").strip()) + primary = safe(start.worktrees(root)[0]["worktree"]) + require(start.configured_mode(primary) == "files" and not (primary / "project.sqlite").exists(), + "SQLite identity requires its registered adapter") + policy = next((p for p in (primary / ".governance/ticket-allocation.json", + primary / "governance/ticket-allocation.json") if p.exists()), None) + if policy: + from ticket_allocation import parse_config + require(parse_config(policy)["mode"] == "local-single-clone", "Registered allocation requires its controller adapter") + lock = safe(common / "new-project-ticket-allocation.lock") + lock.mkdir() # same non-stealable clone lock as allocation and recovery + try: + with recovery.controller_lock(store): + request = recovery.load(request_path) + validate_request(request, workstream) + for location in start.worktrees(primary): + checkout = safe(location["worktree"]) + require(not any(p.is_relative_to(checkout) for p in (store, request_path, Path(request["snapshotPath"]))), + "Controller, request and snapshot must be outside all checkouts") + require(not store.is_relative_to(common), "Controller cannot be stored in candidate Git metadata") + files, intent = load_snapshot(request, workstream) + record = reservation(primary, common, request, workstream) + origin = urlparse(canonical_repository_ref(start.git(primary, "config", "--get", "remote.origin.url").strip())) + require(origin.hostname == "github.com" and origin.scheme in {"https", "git+ssh"}, + "This controller adapter requires a canonical GitHub repository") + repository = origin.path.lstrip("/") + lease = controller(store, request, record, intent, repository) + state = observe(primary, request, record, files, intent) + steps = [] + if state["branchHead"] is None: + start.git(primary, "update-ref", record["branchRef"], state["target"], "") # create-only CAS + steps.append("branch") + if not state["registered"]: + name = record["branchRef"].removeprefix("refs/heads/") + start.git(primary, "worktree", "add", "--relative-paths", str(state["canonical"]), name) + steps.append("checkout") + state = observe(primary, request, record, files, intent) + controller(store, request, record, intent, repository) + if not state["committed"]: + directory = state["canonical"] / "project" / request["ticket"] + directory.mkdir(exist_ok=True) + for name in METADATA: + if name not in state["present"]: + write_new(common, directory / name, files[name]) + steps.append(name) + return {"schema": "new-project.ticket-restoration-result/v1", "ticket": request["ticket"], + "worktreeId": request["worktreeId"], "branchRef": record["branchRef"], + "headSha": state["target"], "outcome": "RESTORED" if steps else "ALREADY_RESTORED", + "steps": steps, "metadata": "committed" if state["committed"] else "snapshot", + "snapshotSha256": request["snapshotSha256"], "leaseId": lease["leaseId"], + "leaseRevision": lease["leaseRevision"], "fencingToken": lease["fencingToken"], + "requestDigest": start.digest(request), "grantsEditAuthority": False, + "grantsMergeAuthority": False, + "nextAction": f"python3 .governance/work_start_check.py --workstream {workstream} " + f"--ticket {request['ticket']}"} + finally: + lock.rmdir() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--request", type=Path, required=True) + parser.add_argument("--lease-store", type=Path, required=True) + parser.add_argument("--workstream", required=True) + args = parser.parse_args() + try: + print(json.dumps(restore(args.root, args.request, args.lease_store, args.workstream))) + return 0 + except recovery.RecoveryError as error: + print("GOV-TICKET-ALLOCATION-003: " + str(error) + "; preserve work and re-observe before retry.", file=sys.stderr) + return 5 + except (OSError, ValueError, KeyError, TypeError, ImportError, start.ObservationError): + print("GOV-TICKET-ALLOCATION-003: restoration rejected; re-observe the reservation, snapshot, controller " + "ownership and registered checkouts; preserve all work.", file=sys.stderr) + return 5 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.governance/ticket_storage.py b/.governance/ticket_storage.py new file mode 100644 index 0000000..03bcda2 --- /dev/null +++ b/.governance/ticket_storage.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +"""Bridge managed ID reservation to a digest-pinned local Registry writer.""" +from __future__ import annotations + +import sys +sys.dont_write_bytecode = True + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess + +from ticket_input import database_rows, no_links, primary_database, read_file + +# Versioned Registry ticket CLI integration: these are its complete relative +# module dependencies. A source update needs a newly acquired independent pin. +RUNTIME_FILES = ("storage-common.mjs", "ticket-store-cli.mjs", "ticket-store.mjs") +MAX_MODULE_BYTES = 1024 * 1024 + +# Sources travel through a bounded stdin frame, never an executable path or an +# argv-sized source string. Read exactly the frame so the writer retains its +# original stdin. The loader uses original URLs for ESM/argv identity while +# serving only bytes from the verified closure. Node builtins remain available +# to the independently trusted writer; this is integrity binding, not a sandbox. +CAPTURED_LOADER = r""" +import {isBuiltin} from 'node:module'; +let sources; +export function initialize(data) { sources = data.sources; } +export async function resolve(specifier, context, nextResolve) { + if (isBuiltin(specifier)) return nextResolve(specifier, context); + const url = new URL(specifier, context.parentURL).href; + if (!Object.hasOwn(sources, url)) throw Error('Unpinned Registry module'); + return {url, shortCircuit: true}; +} +export async function load(url, context, nextLoad) { + if (url.startsWith('node:')) return nextLoad(url, context); + if (!Object.hasOwn(sources, url)) throw Error('Unpinned Registry module'); + return {format: 'module', source: sources[url], shortCircuit: true}; +} +""" +CAPTURED_BOOTSTRAP = r""" +import {readSync} from 'node:fs'; +import {register} from 'node:module'; +import {pathToFileURL} from 'node:url'; +function exact(size) { + const bytes = Buffer.alloc(size); + for (let offset = 0; offset < size;) { + const count = readSync(0, bytes, offset, size - offset, null); + if (!count) throw Error('Incomplete Registry frame'); + offset += count; + } + return bytes; +} +const size = exact(4).readUInt32BE(0); +if (!size || size > 24 * 1024 * 1024) throw Error('Unbounded Registry frame'); +const payload = JSON.parse(exact(size).toString('utf8')); +// The consumer owns URL canonicalization. Python as_uri and Node differ on +// characters such as '~', including Windows temporary-directory aliases. +const sources = Object.fromEntries(Object.entries(payload.sources).map( + ([filename, source]) => [pathToFileURL(filename).href, source] +)); +register('data:text/javascript,' + encodeURIComponent(payload.loader), { + data: {sources} +}); +process.argv = [process.execPath, payload.entryPath, ...payload.args]; +await import(pathToFileURL(payload.entryPath).href); +""" + + +def captured_runtime(root): + """Read each bounded regular source through the same inspected descriptor.""" + root = Path(root).absolute() + no_links(root) + sources = {} + for name in RUNTIME_FILES: + file = root / name + no_links(file) + flags = (os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)) + with os.fdopen(os.open(file, flags), "rb") as stream: + metadata = os.fstat(stream.fileno()) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_MODULE_BYTES: + raise ValueError("bounded complete Registry runtime required") + source = stream.read(MAX_MODULE_BYTES + 1) + if len(source) > MAX_MODULE_BYTES: + raise ValueError("bounded complete Registry runtime required") + sources[name] = source + return sources + + +def sources_digest(sources): + hashes = {name: hashlib.sha256(source).hexdigest() for name, source in sources.items()} + return hashlib.sha256(json.dumps(hashes, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def runtime_digest(root): + return sources_digest(captured_runtime(root)) + + +def verified_sources(root, expected): + if not isinstance(expected, str) or re.fullmatch(r"[a-f0-9]{64}", expected) is None: + raise ValueError("independent Registry runtime pin required") + sources = captured_runtime(root) + if sources_digest(sources) != expected: + raise ValueError("Registry runtime digest mismatch") + return sources + + +def verify_runtime(root, expected): + verified_sources(root, expected) + + +def invoke(root, pin, *args, content=None): + sources = verified_sources(root, pin) + node = shutil.which("node") + if node is None: + raise ValueError("Node runtime required") + root = Path(root).absolute() + entry = root / "ticket-store-cli.mjs" + payload = json.dumps({"sources": {str(root / name): source.decode("utf-8") + for name, source in sources.items()}, + "loader": CAPTURED_LOADER, "entryPath": str(entry), + "args": list(args)}).encode() + if len(payload) > 24 * MAX_MODULE_BYTES: + raise ValueError("bounded Registry frame required") + frame = len(payload).to_bytes(4, "big") + payload + (content or "").encode("utf-8") + result = subprocess.run([node, "--input-type=module", "--eval", CAPTURED_BOOTSTRAP], + input=frame, capture_output=True, timeout=60) + if result.returncode: + raise ValueError("Registry ticket operation failed") + return json.loads(result.stdout) + + +SAFE_SCOPE_TEXT = re.compile(r"[A-Za-z0-9._*/-]{1,160}") + + +class ScopeOwnershipError(ValueError): + """Rejected scope whose message is safe to print: caller patterns and workstream names only.""" + + +def _safe(value): + return value if SAFE_SCOPE_TEXT.fullmatch(value) else "" + + +def ownership_hint(manifest, workstream, unowned): + """Name each unowned pattern and the workstreams that do own it.""" + workstreams = manifest['coordination']['workstreams'] + from governance_check import pattern_covered_by + from work_start_check import patterns + parts = [] + for path in unowned[:5]: + owners = sorted(name for name, entry in workstreams.items() + if any(pattern_covered_by(path, owner) for owner in patterns(entry.get('ownedPaths', [])))) + where = f"owned by {', '.join(_safe(o) for o in owners)}" if owners else "owned by no workstream" + parts.append(f"{_safe(path)} ({where})") + more = f" and {len(unowned) - 5} more" if len(unowned) > 5 else "" + return (f"path(s) not owned by workstream '{_safe(workstream)}': {'; '.join(parts)}{more}. " + "Drop them from --path, or allocate the work in the owning workstream.") + + +def scoped_paths(root, workstream, paths, bootstrap_adoption_digest=None): + """Share the gate's ownership predicate; narrowing is never write authority.""" + if not paths: + return [] + from governance_check import pattern_covered_by + from work_start_check import manifest_at, material, patterns + scope = patterns(paths) + if any(any(part in {"", "."} for part in path.split("/")) for path in scope): + raise ValueError("canonical repository-relative scope required") + manifest = manifest_at(Path(root)) + workstreams = manifest['coordination']['workstreams'] + if workstream not in workstreams: + raise ScopeOwnershipError( + f"unknown workstream '{_safe(workstream)}'; declared: {', '.join(_safe(w) for w in sorted(workstreams))}") + owned = patterns(workstreams[workstream]['ownedPaths']) + if not material(scope): + raise ScopeOwnershipError( + "scope has no implementation path; ticket tracking files alone are not material work") + bootstrap = set() + if bootstrap_adoption_digest is not None: + if (not isinstance(bootstrap_adoption_digest, str) + or re.fullmatch(r'[0-9a-f]{64}', bootstrap_adoption_digest) is None + or workstream not in {'governance', manifest['coordination']['integration']['workstream']}): + raise ScopeOwnershipError('first bootstrap requires an exact digest and its declared owner') + from work_start_check import ObservationError, bootstrap_owned_paths + try: + bootstrap = bootstrap_owned_paths(Path(root), manifest, bootstrap_adoption_digest) + except (ObservationError, OSError, ValueError) as error: + raise ScopeOwnershipError('bootstrap adoption ownership evidence unavailable') from error + unowned = [path for path in scope if not any(pattern_covered_by(path, owner) for owner in owned)] + if (unowned and bootstrap_adoption_digest is not None + and workstream == manifest['coordination']['integration']['workstream']): + # Integration retains dependency-manifest ownership. Its first native + # adoption may also carry the exact installer-owned governance assets; + # this is the gate's verified-adoption exception, not arbitrary scope. + governance = patterns(workstreams['governance']['ownedPaths']) + verified = {path for path in bootstrap + if any(pattern_covered_by(path, owner) for owner in governance)} + unowned = [path for path in unowned if path not in verified] + if (unowned and workstream == 'governance' + and (Path(root) / '.subactor/receipts/bootstrap-adoption.json').exists()): + from work_start_check import ObservationError, bootstrap_seed_paths + try: + seeds = bootstrap_seed_paths(Path(root), manifest) + except (ObservationError, OSError, ValueError) as error: + raise ScopeOwnershipError('bootstrap required seed evidence unavailable') from error + unowned = [path for path in unowned if path not in seeds] + if unowned: + raise ScopeOwnershipError(ownership_hint(manifest, workstream, unowned)) + return scope + + +def persist_scope(args): + if getattr(args, 'bootstrap_adoption_digest', None) is not None and args.ticket is not None: + raise ScopeOwnershipError('bootstrap digest is admission-only; it cannot rewrite a ticket') + scope = scoped_paths(args.root, args.workstream, args.path, + getattr(args, 'bootstrap_adoption_digest', None)) + if args.ticket is not None: + if not scope or re.fullmatch(r"ticket-[0-9]{3,}", args.ticket) is None: + raise ValueError("reserved identity and explicit scope required") + path = args.root / 'project' / args.ticket / 'intent.json' + no_links(path.absolute()) + intent = json.loads(path.read_text(encoding='utf-8')) + if intent['ticket'] != args.ticket or intent['workstream'] != args.workstream: + raise ValueError("allocated intent identity mismatch") + # Replace template implementation placeholders, never broaden admission. + intent['allowedPaths'] = [f'project/{args.ticket}/**', 'TODO.md', 'project/TICKETS.md', *scope] + path.write_text(json.dumps(intent, indent=2) + '\n', encoding='utf-8') + return scope + + +def radar_readme(readme, intent, root): + """Assess initial intent through the external product; never grant authority. + + Radar is advisory and separately installed. A missing/failed runtime is + visible in the README rather than blocking identity allocation or inventing + a score. No network installation, shell command or repository edit occurs. + """ + readme = re.sub(r"\n\n.*?\n?", + "\n", readme, flags=re.S) + heading = "\n\n## Ticket Radar\n\n" + notice = "Initial planned-scope estimate; advisory only, not execution or merge approval.\n" + ticket = {"id": intent["ticket"], "name": intent["summary"], + "description": "", "files": intent["allowedPaths"], + "acceptance_criteria": [], "delivery": intent.get("delivery", {})} + budget = intent.get("delivery", {}).get("estimatedMinutes", 30) + reason = "runtime not installed" + try: + result = subprocess.run( + ["ticket-radar", "--repository-root", str(root)], + input=json.dumps({"ticket": ticket, "budget_minutes": budget}), + capture_output=True, text=True, timeout=10, check=False, + ) + reason = "runtime failed or returned an invalid assessment" + if result.returncode or len(result.stdout) > 1024 * 1024: + raise ValueError("radar invocation failed") + bundle = json.loads(result.stdout) + if not isinstance(bundle, dict) or bundle.get("schema") != "subactor.ticket-radar-bundle/v1": + raise ValueError("unknown radar bundle") + record = json.loads(bundle["jsonl"]) + if (record["schema"] != "subactor.ticket-radar/v1" + or record["ticket_ref"] != intent["ticket"] + or record.get("authority", "ADVISORY") != "ADVISORY" + or record["assessment_mode"] != "predicted" + or record["complexity"] not in {"XS", "S", "M", "L", "XL"} + or type(record["score"]) is not int or not 0 <= record["score"] <= 100 + or type(record["estimate"]["minutes"]) is not int + or not 0 <= record["estimate"]["minutes"] <= 100000 + or type(record["estimate"]["budget_minutes"]) is not int + or not 5 <= record["estimate"]["budget_minutes"] <= 240 + or type(record["split"]["recommended"]) is not bool): + raise ValueError("invalid planned assessment") + diagnostics = record.get("diagnostics", []) + if (not isinstance(diagnostics, list) or len(diagnostics) > 40 + or any(not isinstance(v, str) or not re.fullmatch(r"[A-Z0-9_-]{1,100}", v) + for v in diagnostics)): + raise ValueError("invalid diagnostics") + body = ("| Measure | Initial assessment |\n| --- | --- |\n" + f"| Impact score | {record['score']}/100 |\n" + f"| Complexity | {record['complexity']} |\n" + f"| Estimated effort | {record['estimate']['minutes']} min |\n" + f"| Planning budget | {record['estimate']['budget_minutes']} min |\n" + f"| Split recommended | {'yes' if record['split']['recommended'] else 'no'} |\n") + if diagnostics: + body += "\nDiagnostics: " + ", ".join(diagnostics) + ".\n" + except subprocess.TimeoutExpired: + body = "Assessment unavailable: runtime timed out.\n" + except (OSError, ValueError, KeyError, TypeError): + body = "Assessment unavailable: " + reason + ".\n" + return readme.rstrip() + "\n" + heading + notice + "\n" + body + "\n" + + +def assess_file_ticket(args): + if re.fullmatch(r"ticket-[0-9]{3,}", args.ticket or "") is None: + raise ValueError("reserved ticket identity required") + directory = args.root / "project" / args.ticket + for name in ("README.md", "intent.json"): + no_links((directory / name).absolute()) + intent = json.loads((directory / "intent.json").read_text(encoding="utf-8")) + if intent["ticket"] != args.ticket: + raise ValueError("allocated intent identity mismatch") + path = directory / "README.md" + path.write_text(radar_readme(path.read_text(encoding="utf-8"), intent, args.root), encoding="utf-8") + + +def create(args): + ticket = args.ticket + if re.fullmatch(r"ticket-[0-9]{3,}", ticket or "") is None: + raise ValueError("reserved ticket identity required") + if not args.title or "\n" in args.title or "\r" in args.title: + raise ValueError("single-line title required") + scope = scoped_paths(args.root, args.workstream, args.path) + intent = {"schema": "new-project.intent/v3", "ticket": ticket, "summary": args.title, + "workstream": args.workstream, + "classification": {"kind": args.kind, "priority": args.priority, "origin": args.origin}, + # Retain the admitted scope; complete delivery intent and fencing before + # editing. With no explicit scope, retain the conservative old seed. + "allowedPaths": [f"project/{ticket}/**", *scope], "forbiddenPaths": ["project/ticket-*/user-*.md"], + "stacks": [], "dependsOn": [], "conflictsWith": [], "integrationTicket": None} + readme = (f"# {args.title}\n\n- **Status**: IN_PROGRESS\n- **Workflow state**: EDIT\n\n" + "## Goal and scope\n\nComplete the bounded intent in SQLite before implementation.\n") + readme = radar_readme(readme, intent, args.root) + content = json.dumps({"README.md": readme, "intent.json": json.dumps(intent, indent=2) + "\n"}) + invoke(args.runtime_root, args.runtime_sha256, "init", "--repository", str(args.root)) + receipt = invoke(args.runtime_root, args.runtime_sha256, "create", "--repository", str(args.root), + "--ticket", ticket, "--allocation-key", args.allocation_key, content=content) + return {**receipt, "runtime_sha256": args.runtime_sha256, "storage": "sqlite"} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["digest", "verify", "highest", "create", "active", "scope", "radar"]) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--runtime-root") + parser.add_argument("--runtime-sha256") + parser.add_argument("--active-status", action="append", default=[]) + parser.add_argument("--path", action="append", default=[]) + parser.add_argument("--bootstrap-adoption-digest") + for name in ("ticket", "title", "workstream", "kind", "priority", "origin", "allocation-key"): + parser.add_argument("--" + name) + args = parser.parse_args() + try: + if args.bootstrap_adoption_digest is not None and args.command != 'scope': + raise ValueError('bootstrap digest is scope-admission-only') + if args.command == "radar": + assess_file_ticket(args) + elif args.command == "scope": + print(json.dumps(persist_scope(args))) + elif args.command == "active": + import ticket_activity + text = read_file(args.root, args.ticket, "README.md").decode("utf-8") + status, _ = ticket_activity.parse_ticket_state_text(text) + if status is None: + raise ValueError("ticket status required") + result = ticket_activity.resolve(args.root, args.root / "project" / args.ticket, set(args.active_status), status_override=status) + raise SystemExit(0 if result.active else 1) + elif args.command == "highest": + database = primary_database(args.root) + rows = database_rows(args.root, database) if database.exists() else [] + print(max((int(row[0].removeprefix("ticket-")) for row in rows), default=0)) + elif args.command == "digest": + print(runtime_digest(args.runtime_root)) + elif args.command == "verify": + verify_runtime(args.runtime_root, args.runtime_sha256) + else: + print(json.dumps(create(args))) + except Exception as error: + # Do not echo command input, ticket contents or child stderr. A scope + # ownership rejection holds only validated caller patterns and manifest + # workstream names, so it is named instead of the generic diagnostic. + if args.command == "scope" and isinstance(error, ScopeOwnershipError): + parser.exit(3, f"GOV-WORK-START-001: {error}\n") + if args.command == "scope": + parser.exit(3, "GOV-WORK-START-001: invalid scope, unowned paths or missing managed scope runtime.\n") + parser.exit(2 if args.command == "active" else 1, "GOV-TICKET-ALLOCATION-003: SQLite storage or pinned runtime validation failed.\n") + + +if __name__ == "__main__": + main() 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/.governance/work-continuity.schema.json b/.governance/work-continuity.schema.json new file mode 100644 index 0000000..4d6f129 --- /dev/null +++ b/.governance/work-continuity.schema.json @@ -0,0 +1,320 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/work-continuity.schema.json", + "title": "Wellmanifest work continuity v2", + "oneOf": [ + { "$ref": "#/$defs/checkpoint" }, + { "$ref": "#/$defs/event" }, + { "$ref": "#/$defs/index" } + ], + "$defs": { + "sha1": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "ticket": { "type": "string", "pattern": "^ticket-[0-9]{3,}$" }, + "safeId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "workstream": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "gitRef": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]*$" + }, + "opaqueRef": { + "type": "string", + "minLength": 3, + "maxLength": 512, + "pattern": "^(artifact|authorization|decision|knowledge|receipt):[a-z0-9][a-z0-9._:/-]*$" + }, + "artifactRef": { + "type": "string", + "minLength": 3, + "maxLength": 512, + "pattern": "^artifact:[a-z0-9][a-z0-9._:/-]*$" + }, + "authorizationRef": { + "type": "string", + "minLength": 3, + "maxLength": 512, + "pattern": "^authorization:[a-z0-9][a-z0-9._:/-]*$" + }, + "receiptRef": { + "type": "string", + "minLength": 3, + "maxLength": 512, + "pattern": "^receipt:[a-z0-9][a-z0-9._:/-]*$" + }, + "repositoryRef": { + "type": "string", + "minLength": 12, + "maxLength": 512, + "pattern": "^(?!.*(?:[.][.]|//))repository:[A-Za-z0-9][A-Za-z0-9._/-]*$" + }, + "accountRef": { + "type": "string", + "minLength": 9, + "maxLength": 264, + "pattern": "^account:[a-z0-9][a-z0-9._:/-]*$" + }, + "criterion": { "type": "string", "pattern": "^AC-[0-9]{2,}$" }, + "criteria": { + "type": "array", + "maxItems": 128, + "uniqueItems": true, + "items": { "$ref": "#/$defs/criterion" } + }, + "binding": { + "type": "object", + "additionalProperties": false, + "required": ["ref", "sha256"], + "properties": { + "ref": { "$ref": "#/$defs/artifactRef" }, + "sha256": { "$ref": "#/$defs/sha256" } + } + }, + "slice": { + "type": "object", + "additionalProperties": false, + "required": ["ref", "sha256", "ordinal", "total"], + "properties": { + "ref": { "$ref": "#/$defs/artifactRef" }, + "sha256": { "$ref": "#/$defs/sha256" }, + "ordinal": { "type": "integer", "minimum": 1 }, + "total": { "type": "integer", "minimum": 1 } + } + }, + "lease": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["leaseRef", "leaseRevision", "fencingToken"], + "properties": { + "leaseRef": { "$ref": "#/$defs/receiptRef" }, + "leaseRevision": { "type": "integer", "minimum": 1 }, + "fencingToken": { "type": "integer", "minimum": 1 } + } + } + ] + }, + "remoteObservation": { + "type": "object", + "additionalProperties": false, + "required": ["remoteName", "repositoryRef", "accountRef", "observedAt", "receiptRef"], + "properties": { + "remoteName": { "$ref": "#/$defs/safeId" }, + "repositoryRef": { "$ref": "#/$defs/repositoryRef" }, + "accountRef": { "$ref": "#/$defs/accountRef" }, + "observedAt": { "type": "string", "format": "date-time" }, + "receiptRef": { "$ref": "#/$defs/receiptRef" } + } + }, + "workspace": { + "type": "object", + "additionalProperties": false, + "required": [ + "state", "resumeSource", "statusSha256", "snapshotRef", "snapshotSha256", + "snapshotReceipt", "secretScanReceipt" + ], + "properties": { + "state": { "enum": ["clean", "snapshotted"] }, + "resumeSource": { "enum": ["commit", "snapshot"] }, + "statusSha256": { "$ref": "#/$defs/sha256" }, + "snapshotRef": { + "oneOf": [{ "$ref": "#/$defs/artifactRef" }, { "type": "null" }] + }, + "snapshotSha256": { + "oneOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] + }, + "snapshotReceipt": { + "oneOf": [{ "$ref": "#/$defs/receiptRef" }, { "type": "null" }] + }, + "secretScanReceipt": { + "oneOf": [{ "$ref": "#/$defs/receiptRef" }, { "type": "null" }] + } + }, + "oneOf": [ + { + "properties": { + "state": { "const": "clean" }, + "resumeSource": { "const": "commit" }, + "statusSha256": { + "const": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "snapshotRef": { "type": "null" }, + "snapshotSha256": { "type": "null" }, + "snapshotReceipt": { "type": "null" }, + "secretScanReceipt": { "type": "null" } + } + }, + { + "properties": { + "state": { "const": "snapshotted" }, + "resumeSource": { "const": "snapshot" }, + "statusSha256": { + "not": { + "const": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + "snapshotRef": { "$ref": "#/$defs/artifactRef" }, + "snapshotSha256": { "$ref": "#/$defs/sha256" }, + "snapshotReceipt": { "$ref": "#/$defs/receiptRef" }, + "secretScanReceipt": { "$ref": "#/$defs/receiptRef" } + } + } + ] + }, + "pendingEffect": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "state", "idempotencyKey", "effectRef"], + "properties": { + "kind": { + "enum": ["push", "pull-request", "validation", "merge", "release", "external-coordination"] + }, + "state": { "enum": ["planned", "in-flight", "failed"] }, + "idempotencyKey": { + "type": "string", + "pattern": "^idempotency:[a-z0-9][a-z0-9._-]{0,127}$" + }, + "effectRef": { + "oneOf": [{ "$ref": "#/$defs/opaqueRef" }, { "type": "null" }] + } + } + }, + "nextAction": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "criterion"], + "properties": { + "kind": { "enum": ["observe", "edit", "validate", "publish", "reconcile", "wait"] }, + "criterion": { + "oneOf": [{ "$ref": "#/$defs/criterion" }, { "type": "null" }] + } + } + }, + "checkpoint": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", "authority", "checkpointRef", "previousCheckpointRef", "sequence", + "repositoryRef", "ticket", "workstream", "intentRef", "intentSha256", + "scopeSha256", "plan", "slice", "targetBranch", "branchRef", "headSha", + "worktreeId", "phase", "authorizationRef", "lease", "remoteObservation", + "workspace", "completedCriteria", "remainingCriteria", "evidenceRefs", + "pendingEffects", "nextAction", "recordedAt" + ], + "properties": { + "schema": { "const": "new-project.work-continuity/v2" }, + "authority": { "const": "advisory-projection" }, + "checkpointRef": { "$ref": "#/$defs/receiptRef" }, + "previousCheckpointRef": { + "oneOf": [{ "$ref": "#/$defs/receiptRef" }, { "type": "null" }] + }, + "sequence": { "type": "integer", "minimum": 1 }, + "repositoryRef": { "$ref": "#/$defs/repositoryRef" }, + "ticket": { "$ref": "#/$defs/ticket" }, + "workstream": { "$ref": "#/$defs/workstream" }, + "intentRef": { "$ref": "#/$defs/artifactRef" }, + "intentSha256": { "$ref": "#/$defs/sha256" }, + "scopeSha256": { "$ref": "#/$defs/sha256" }, + "plan": { "$ref": "#/$defs/binding" }, + "slice": { "$ref": "#/$defs/slice" }, + "targetBranch": { "$ref": "#/$defs/gitRef" }, + "branchRef": { "$ref": "#/$defs/gitRef" }, + "headSha": { "$ref": "#/$defs/sha1" }, + "worktreeId": { "$ref": "#/$defs/safeId" }, + "phase": { + "enum": ["analysis", "plan", "tools", "edit", "validation", "publication", "blocked"] + }, + "authorizationRef": { "$ref": "#/$defs/authorizationRef" }, + "lease": { "$ref": "#/$defs/lease" }, + "remoteObservation": { "$ref": "#/$defs/remoteObservation" }, + "workspace": { "$ref": "#/$defs/workspace" }, + "completedCriteria": { "$ref": "#/$defs/criteria" }, + "remainingCriteria": { "$ref": "#/$defs/criteria" }, + "evidenceRefs": { + "type": "array", + "maxItems": 128, + "uniqueItems": true, + "items": { "$ref": "#/$defs/opaqueRef" } + }, + "pendingEffects": { + "type": "array", + "maxItems": 32, + "items": { "$ref": "#/$defs/pendingEffect" } + }, + "nextAction": { "$ref": "#/$defs/nextAction" }, + "recordedAt": { "type": "string", "format": "date-time" } + }, + "allOf": [ + { + "if": { "properties": { "sequence": { "const": 1 } }, "required": ["sequence"] }, + "then": { "properties": { "previousCheckpointRef": { "type": "null" } } }, + "else": { "properties": { "previousCheckpointRef": { "$ref": "#/$defs/receiptRef" } } } + } + ] + }, + "event": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "eventRef", "previousEventRef", "eventSequence", "sessionId", "checkpoint"], + "properties": { + "schema": { "const": "new-project.work-continuity-event/v2" }, + "eventRef": { "$ref": "#/$defs/receiptRef" }, + "previousEventRef": { + "oneOf": [{ "$ref": "#/$defs/receiptRef" }, { "type": "null" }] + }, + "eventSequence": { "type": "integer", "minimum": 1 }, + "sessionId": { "$ref": "#/$defs/safeId" }, + "checkpoint": { "$ref": "#/$defs/checkpoint" } + }, + "allOf": [ + { + "if": { "properties": { "eventSequence": { "const": 1 } }, "required": ["eventSequence"] }, + "then": { "properties": { "previousEventRef": { "type": "null" } } }, + "else": { "properties": { "previousEventRef": { "$ref": "#/$defs/receiptRef" } } } + } + ] + }, + "indexEntry": { + "type": "object", + "additionalProperties": false, + "required": ["ticket", "sessionId", "eventRef", "checkpointRef", "checkpointSequence", "recordedAt"], + "properties": { + "ticket": { "$ref": "#/$defs/ticket" }, + "sessionId": { "$ref": "#/$defs/safeId" }, + "eventRef": { "$ref": "#/$defs/receiptRef" }, + "checkpointRef": { "$ref": "#/$defs/receiptRef" }, + "checkpointSequence": { "type": "integer", "minimum": 1 }, + "recordedAt": { "type": "string", "format": "date-time" } + } + }, + "index": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "repositoryRef", "maxEntries", "entries", "updatedAt"], + "properties": { + "schema": { "const": "new-project.work-continuity-index/v2" }, + "repositoryRef": { "$ref": "#/$defs/repositoryRef" }, + "maxEntries": { "type": "integer", "minimum": 1, "maximum": 128 }, + "entries": { + "type": "array", + "maxItems": 128, + "items": { "$ref": "#/$defs/indexEntry" } + }, + "updatedAt": { "type": "string", "format": "date-time" } + } + } + } +} diff --git a/.governance/work-start-report.schema.json b/.governance/work-start-report.schema.json new file mode 100644 index 0000000..106193e --- /dev/null +++ b/.governance/work-start-report.schema.json @@ -0,0 +1,429 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:wellmanifest:new-project:schema:work-start-report:v1", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "readOnly", + "grantsAuthority", + "createsWorktree", + "scope", + "primaryCheckout", + "targetBranch", + "targetObservations", + "remoteFreshness", + "workstream", + "requestedPaths", + "requestedTicket", + "route", + "diagnostic", + "worktrees", + "uncheckedBranches", + "blockers", + "activeTicketCount", + "workstreamLimit", + "requiredBeforeWrite", + "observationDigest" + ], + "properties": { + "schema": { + "const": "new-project.work-start-report/v1" + }, + "readOnly": { + "const": true + }, + "grantsAuthority": { + "const": false + }, + "createsWorktree": { + "const": false + }, + "scope": { + "const": "registered-clone-local" + }, + "primaryCheckout": { + "type": "string" + }, + "targetBranch": { + "type": "string" + }, + "targetObservations": { + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^[a-f0-9]{40,64}$" + } + }, + "remoteFreshness": { + "const": "not-refreshed" + }, + "workstream": { + "type": "string" + }, + "requestedPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "requestedTicket": { + "type": [ + "string", + "null" + ] + }, + "route": { + "enum": [ + "NEW_TICKET_CANDIDATE", + "REUSE_EXISTING", + "RECONCILE", + "ASSIST_READ_ONLY", + "HANDOFF_REQUIRED", + "SERIALIZE" + ] + }, + "diagnostic": { + "enum": [ + null, + "GOV-WORK-START-001" + ] + }, + "worktrees": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "branch", + "headSha", + "ahead", + "behind", + "dirtyPaths", + "dirtyDigest", + "dirtyNewestModifiedAt", + "pending", + "ticket", + "workstream", + "allowedPaths", + "intentDigest", + "active", + "status", + "activityAuthority", + "canonical", + "writerAuthority", + "allDirtyPaths" + ], + "properties": { + "path": { + "type": "string" + }, + "branch": { + "type": [ + "string", + "null" + ] + }, + "headSha": { + "type": "string" + }, + "ahead": { + "type": "integer", + "minimum": 0 + }, + "behind": { + "type": "integer", + "minimum": 0 + }, + "dirtyPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "dirtyDigest": { + "type": "string" + }, + "dirtyNewestModifiedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "pending": { + "type": "boolean" + }, + "ticket": { + "type": [ + "string", + "null" + ] + }, + "workstream": { + "type": [ + "string", + "null" + ] + }, + "allowedPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "intentDigest": { + "type": [ + "string", + "null" + ] + }, + "active": { + "type": "boolean" + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "activityAuthority": { + "type": "string" + }, + "canonical": { + "type": "boolean" + }, + "writerAuthority": { + "const": "unverified" + }, + "allDirtyPaths": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "uncheckedBranches": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "branch", + "headSha", + "ahead", + "behind" + ], + "properties": { + "branch": { + "type": "string" + }, + "headSha": { + "type": "string" + }, + "ahead": { + "type": "integer" + }, + "behind": { + "type": "integer" + } + } + } + }, + "blockers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "branch", + "ticket", + "active", + "reason" + ], + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "branch": { + "type": [ + "string", + "null" + ] + }, + "ticket": { + "type": [ + "string", + "null" + ] + }, + "active": { + "type": "boolean" + }, + "reason": { + "enum": [ + "scope-reservation", + "pending-delta", + "unassigned-branch-delta", + "unassigned-ticket", + "integrated-ticket-carrier", + "selected-checkout-changed" + ] + } + } + } + }, + "activeTicketCount": { + "type": "integer", + "minimum": 0 + }, + "workstreamLimit": { + "type": "integer", + "minimum": 1 + }, + "requiredBeforeWrite": { + "type": "array", + "items": { + "type": "string" + } + }, + "observationDigest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "publication": { + "$ref": "#/$defs/publicationObservation" + }, + "editingResource": { + "$ref": "#/$defs/editingResourceObservation" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "readOnly", + "grantsAuthority", + "createsWorktree", + "route", + "diagnostic", + "reason" + ], + "properties": { + "schema": { + "const": "new-project.work-start-report/v1" + }, + "readOnly": { + "const": true + }, + "grantsAuthority": { + "const": false + }, + "createsWorktree": { + "const": false + }, + "route": { + "const": "RECONCILE" + }, + "diagnostic": { + "const": "GOV-WORK-START-001" + }, + "reason": { + "type": "string" + } + } + } + ], + "$defs": { + "editingResourceObservation": { + "$anchor": "editingResourceObservation", + "type": "object", + "additionalProperties": false, + "required": ["schema", "observedAt", "readOnly", "grantsAuthority", "queueSupported", "repositoryRef", "targetBranch", "ticket", "resource", "state", "reason", "holder", "stateDigest", "nextAction"], + "properties": { + "schema": {"const": "new-project.editing-resource-observation/v1"}, + "observedAt": {"type": "string", "format": "date-time"}, + "readOnly": {"const": true}, + "grantsAuthority": {"const": false}, + "queueSupported": {"type": "boolean"}, + "repositoryRef": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}, + "targetBranch": {"type": "string"}, + "ticket": {"type": ["string", "null"]}, + "resource": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "state": {"enum": ["AVAILABLE", "QUEUED", "HELD_BY_TICKET", "HELD_BY_OTHER", "HELD_EXPIRED", "UNAVAILABLE"]}, + "reason": {"enum": [null, "controller-store-missing", "controller-state-unreadable", "controller-state-invalid", "controller-state-changed", "controller-transport-failure", "controller-pin-mismatch", "controller-policy-denied"]}, + "holder": { + "oneOf": [ + {"type": "null"}, + { + "type": "object", + "additionalProperties": false, + "required": ["leaseId", "ticketId", "phase", "expiresAt"], + "properties": { + "leaseId": {"type": "string"}, + "ticketId": {"type": ["string", "null"]}, + "phase": {"type": "string"}, + "expiresAt": {"type": "string"} + } + } + ] + }, + "stateDigest": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}, + "nextAction": {"type": "string", "minLength": 1} + } + }, + "publicationObservation": { + "$anchor": "publicationObservation", + "type": "object", + "additionalProperties": false, + "required": ["schema", "observedAt", "readOnly", "grantsAuthority", "remote", "scope", "status", "remoteRefsDigest", "targetRef", "notObservedStages", "worktrees"], + "properties": { + "schema": {"const": "new-project.publication-observation/v1"}, + "observedAt": {"type": "string", "format": "date-time"}, + "readOnly": {"const": true}, + "grantsAuthority": {"const": false}, + "remote": {"const": "origin"}, + "scope": {"const": "origin-heads"}, + "status": {"enum": ["observed", "partial", "unavailable", "changed"]}, + "remoteRefsDigest": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}, + "targetRef": {"type": "string", "pattern": "^refs/heads/.+"}, + "notObservedStages": {"const": ["pull-request", "checks", "approval", "protected-merge", "release", "deployment"]}, + "worktrees": { + "type": "array", + "items": {"$ref": "#/$defs/publicationWorktree"} + } + } + }, + "publicationWorktree": { + "type": "object", + "additionalProperties": false, + "required": ["path", "branch", "headSha", "uncommittedPathCount", "remoteContainingRefs", "unpublishedCommitCount", "sameBranchContainsHead", "headReachableFromTarget", "nextAction"], + "properties": { + "path": {"type": "string"}, + "branch": {"type": ["string", "null"]}, + "headSha": {"type": "string", "pattern": "^([a-f0-9]{40}|[a-f0-9]{64})$"}, + "uncommittedPathCount": {"type": "integer", "minimum": 0}, + "remoteContainingRefs": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string", "pattern": "^refs/heads/.+"} + }, + "unpublishedCommitCount": {"type": ["integer", "null"], "minimum": 0}, + "sameBranchContainsHead": {"type": ["boolean", "null"]}, + "headReachableFromTarget": {"type": ["boolean", "null"]}, + "nextAction": {"enum": ["preserve-local-work", "observe-remote", "review-push-preconditions", "reconcile-branch-binding", "observe-integration-evidence", "observe-review-release-deployment"]} + } + } + } +} diff --git a/.governance/work_continuity.py b/.governance/work_continuity.py new file mode 100755 index 0000000..74b93a6 --- /dev/null +++ b/.governance/work_continuity.py @@ -0,0 +1,1363 @@ +#!/usr/bin/env python3 +"""Capture and verify host-agnostic local continuity events. + +The JSONL stream is append-only and has no policy size cap. The checkpoint +index is a bounded, atomically replaced acceleration structure; it is never an +authority source and can always be rebuilt from the stream. Cross-machine +durability still requires a protected external receipt store. +""" + +from __future__ import annotations + +import argparse +from contextlib import ExitStack, contextmanager +import errno +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timezone +from typing import Any, Iterable +from urllib.parse import urlsplit + + +CHECKPOINT_SCHEMA = "new-project.work-continuity/v2" +EVENT_SCHEMA = "new-project.work-continuity-event/v2" +INDEX_SCHEMA = "new-project.work-continuity-index/v2" +MANIFEST_SCHEMA = "new-project.subactor-local/v1" +EMPTY_SHA256 = hashlib.sha256(b"").hexdigest() +SHA1_RE = re.compile(r"^[0-9a-f]{40}$") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +TICKET_RE = re.compile(r"^ticket-[0-9]{3,}$") +WORKSTREAM_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") +SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$") +REPOSITORY_RE = re.compile(r"^repository:[A-Za-z0-9][A-Za-z0-9._/-]{0,500}$") +ACCOUNT_RE = re.compile(r"^account:[a-z0-9][a-z0-9._:/-]{0,255}$") +CRITERION_RE = re.compile(r"^AC-[0-9]{2,}$") +REFERENCE_RE = re.compile( + r"^(artifact|authorization|decision|knowledge|receipt):" + r"[a-z0-9][a-z0-9._:/-]{0,510}$" +) +IDEMPOTENCY_RE = re.compile(r"^idempotency:[a-z0-9][a-z0-9._-]{0,127}$") +VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$") +PHASES = {"analysis", "plan", "tools", "edit", "validation", "publication", "blocked"} +NEXT_ACTIONS = {"observe", "edit", "validate", "publish", "reconcile", "wait"} +EFFECT_KINDS = { + "push", "pull-request", "validation", "merge", "release", "external-coordination" +} +EFFECT_STATES = {"planned", "in-flight", "failed"} +IGNORED_DIRECTORIES = [ + ".subactor/leases/", + ".subactor/sessions/", + ".subactor/recovery/", + ".subactor/receipts/", + ".subactor/cache/", + ".subactor/snapshots/", +] + + +class ContinuityError(RuntimeError): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +def fail(code: str, message: str) -> None: + raise ContinuityError(code, message) + + +def canonical_bytes(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def canonical_digest(value: Any) -> str: + return hashlib.sha256(canonical_bytes(value)).hexdigest() + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def load_json(path: Path, *, maximum: int = 1024 * 1024) -> Any: + try: + if path.stat().st_size > maximum: + fail("GOV-CONTINUITY-001", f"bounded continuity document is too large: {path}") + return json.loads(path.read_text(encoding="utf-8")) + except ContinuityError: + raise + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + fail("GOV-CONTINUITY-001", f"cannot read continuity document {path}: {exc}") + + +def exact_object(value: Any, fields: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + fail("GOV-CONTINUITY-001", f"{label} fields are invalid") + return value + + +def string(value: Any, label: str, *, maximum: int = 512) -> str: + if not isinstance(value, str) or not value or len(value) > maximum: + fail("GOV-CONTINUITY-001", f"{label} must be a bounded non-empty string") + if any(ord(character) < 32 for character in value): + fail("GOV-CONTINUITY-001", f"{label} contains control characters") + return value + + +def safe_id(value: Any, label: str) -> str: + if not isinstance(value, str) or SAFE_ID_RE.fullmatch(value) is None: + fail("GOV-CONTINUITY-001", f"{label} is invalid") + return value + + +def reference(value: Any, label: str, *, kind: str | None = None) -> str: + result = string(value, label) + match = REFERENCE_RE.fullmatch(result) + if match is None or (kind is not None and match.group(1) != kind): + fail("GOV-CONTINUITY-001", f"{label} is not an allowed opaque {kind or 'evidence'} reference") + return result + + +def sha(value: Any, label: str, expression: re.Pattern[str]) -> str: + if not isinstance(value, str) or expression.fullmatch(value) is None: + fail("GOV-CONTINUITY-001", f"{label} has an invalid digest") + return value + + +def timestamp(value: Any, label: str = "recordedAt") -> str: + result = string(value, label, maximum=64) + if not result.endswith("Z"): + fail("GOV-CONTINUITY-001", f"{label} must be UTC and end with Z") + try: + parsed = datetime.fromisoformat(result[:-1] + "+00:00") + except ValueError: + fail("GOV-CONTINUITY-001", f"{label} is not an RFC 3339 timestamp") + if parsed.tzinfo != timezone.utc: + fail("GOV-CONTINUITY-001", f"{label} must use UTC") + return result + + +def unique_strings( + value: Any, label: str, expression: re.Pattern[str], maximum: int = 128 +) -> list[str]: + if not isinstance(value, list) or len(value) > maximum: + fail("GOV-CONTINUITY-001", f"{label} must be a bounded array") + if any(not isinstance(item, str) or expression.fullmatch(item) is None for item in value): + fail("GOV-CONTINUITY-001", f"{label} contains an invalid item") + if len(value) != len(set(value)): + fail("GOV-CONTINUITY-001", f"{label} contains duplicates") + return value + + +def git_ref(value: Any, label: str) -> str: + result = string(value, label, maximum=255) + invalid = ( + result.startswith(("/", ".")) + or result.endswith(("/", ".", ".lock")) + or any(token in result for token in ("..", "//", "@{", "\\", " ")) + or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", result) is None + ) + if invalid: + fail("GOV-CONTINUITY-001", f"{label} is not a safe Git ref") + return result + + +def validate_repository(value: Any, label: str) -> str: + repository = string(value, label) + if REPOSITORY_RE.fullmatch(repository) is None or ".." in repository or "//" in repository: + fail("GOV-CONTINUITY-001", f"{label} is not a credential-free repository identity") + return repository + + +def validate_binding(value: Any, label: str) -> dict[str, Any]: + binding = exact_object(value, {"ref", "sha256"}, label) + reference(binding["ref"], f"{label}.ref", kind="artifact") + sha(binding["sha256"], f"{label}.sha256", SHA256_RE) + return binding + + +def validate_slice(value: Any) -> dict[str, Any]: + binding = exact_object(value, {"ref", "sha256", "ordinal", "total"}, "slice") + reference(binding["ref"], "slice.ref", kind="artifact") + sha(binding["sha256"], "slice.sha256", SHA256_RE) + for field in ("ordinal", "total"): + if not isinstance(binding[field], int) or isinstance(binding[field], bool) or binding[field] < 1: + fail("GOV-CONTINUITY-001", f"slice.{field} must be a positive integer") + if binding["ordinal"] > binding["total"]: + fail("GOV-CONTINUITY-001", "slice ordinal cannot exceed total") + return binding + + +def validate_lease(value: Any) -> dict[str, Any] | None: + if value is None: + return None + lease = exact_object(value, {"leaseRef", "leaseRevision", "fencingToken"}, "lease") + reference(lease["leaseRef"], "lease.leaseRef", kind="receipt") + for field in ("leaseRevision", "fencingToken"): + if not isinstance(lease[field], int) or isinstance(lease[field], bool) or lease[field] < 1: + fail("GOV-CONTINUITY-001", f"lease.{field} must be a positive integer") + return lease + + +def validate_remote(value: Any, repository: str) -> dict[str, Any]: + observation = exact_object( + value, + {"remoteName", "repositoryRef", "accountRef", "observedAt", "receiptRef"}, + "remoteObservation", + ) + safe_id(observation["remoteName"], "remoteObservation.remoteName") + if validate_repository(observation["repositoryRef"], "remoteObservation.repositoryRef") != repository: + fail("GOV-CONTINUITY-002", "remote observation belongs to another repository") + if not isinstance(observation["accountRef"], str) or ACCOUNT_RE.fullmatch(observation["accountRef"]) is None: + fail("GOV-CONTINUITY-001", "remoteObservation.accountRef is invalid") + timestamp(observation["observedAt"], "remoteObservation.observedAt") + reference(observation["receiptRef"], "remoteObservation.receiptRef", kind="receipt") + return observation + + +def validate_workspace(value: Any) -> dict[str, Any]: + workspace = exact_object( + value, + { + "state", "resumeSource", "statusSha256", "snapshotRef", "snapshotSha256", + "snapshotReceipt", "secretScanReceipt", + }, + "workspace", + ) + sha(workspace["statusSha256"], "workspace.statusSha256", SHA256_RE) + snapshot_fields = ("snapshotRef", "snapshotSha256", "snapshotReceipt", "secretScanReceipt") + if workspace["state"] == "clean" and workspace["resumeSource"] == "commit": + if workspace["statusSha256"] != EMPTY_SHA256 or any(workspace[field] is not None for field in snapshot_fields): + fail("GOV-CONTINUITY-001", "clean workspace must bind only the exact committed HEAD") + elif workspace["state"] == "snapshotted" and workspace["resumeSource"] == "snapshot": + reference(workspace["snapshotRef"], "workspace.snapshotRef", kind="artifact") + sha(workspace["snapshotSha256"], "workspace.snapshotSha256", SHA256_RE) + reference(workspace["snapshotReceipt"], "workspace.snapshotReceipt", kind="receipt") + reference(workspace["secretScanReceipt"], "workspace.secretScanReceipt", kind="receipt") + if workspace["statusSha256"] == EMPTY_SHA256: + fail("GOV-CONTINUITY-001", "snapshotted workspace must bind a non-empty status digest") + else: + fail("GOV-CONTINUITY-001", "workspace is resumable only from commit or snapshot") + return workspace + + +def validate_pending_effect(value: Any, index: int) -> dict[str, Any]: + effect = exact_object( + value, {"kind", "state", "idempotencyKey", "effectRef"}, f"pendingEffects[{index}]" + ) + if effect["kind"] not in EFFECT_KINDS or effect["state"] not in EFFECT_STATES: + fail("GOV-CONTINUITY-001", f"pendingEffects[{index}] uses an unsupported enum") + if not isinstance(effect["idempotencyKey"], str) or IDEMPOTENCY_RE.fullmatch(effect["idempotencyKey"]) is None: + fail("GOV-CONTINUITY-001", f"pendingEffects[{index}].idempotencyKey is invalid") + if effect["effectRef"] is not None: + reference(effect["effectRef"], f"pendingEffects[{index}].effectRef") + return effect + + +def validate_checkpoint_effects(checkpoint): + effects = checkpoint["pendingEffects"] + if not isinstance(effects, list) or len(effects) > 32: + fail("GOV-CONTINUITY-001", "pendingEffects must be a bounded array") + validated_effects = [validate_pending_effect(item, index) for index, item in enumerate(effects)] + keys = [item["idempotencyKey"] for item in validated_effects] + if len(keys) != len(set(keys)): + fail("GOV-CONTINUITY-001", "pending effect idempotency keys must be unique") + + +def validate_checkpoint_progress(checkpoint): + completed = unique_strings(checkpoint["completedCriteria"], "completedCriteria", CRITERION_RE) + remaining = unique_strings(checkpoint["remainingCriteria"], "remainingCriteria", CRITERION_RE) + if set(completed) & set(remaining): + fail("GOV-CONTINUITY-001", "completed and remaining criteria overlap") + evidence = checkpoint["evidenceRefs"] + if not isinstance(evidence, list) or len(evidence) > 128: + fail("GOV-CONTINUITY-001", "evidenceRefs must be a bounded array") + for index, item in enumerate(evidence): + reference(item, f"evidenceRefs[{index}]") + if len(evidence) != len(set(evidence)): + fail("GOV-CONTINUITY-001", "evidenceRefs contains duplicates") + validate_checkpoint_effects(checkpoint) + next_action = exact_object(checkpoint["nextAction"], {"kind", "criterion"}, "nextAction") + if next_action["kind"] not in NEXT_ACTIONS: + fail("GOV-CONTINUITY-001", "nextAction.kind is invalid") + if next_action["criterion"] is not None and ( + not isinstance(next_action["criterion"], str) + or CRITERION_RE.fullmatch(next_action["criterion"]) is None + or next_action["criterion"] not in remaining + ): + fail("GOV-CONTINUITY-001", "next action criterion must remain unfinished") + + +def validate_checkpoint(value: Any) -> dict[str, Any]: + fields = { + "schema", "authority", "checkpointRef", "previousCheckpointRef", "sequence", + "repositoryRef", "ticket", "workstream", "intentRef", "intentSha256", + "scopeSha256", "plan", "slice", "targetBranch", "branchRef", "headSha", + "worktreeId", "phase", "authorizationRef", "lease", "remoteObservation", + "workspace", "completedCriteria", "remainingCriteria", "evidenceRefs", + "pendingEffects", "nextAction", "recordedAt", + } + checkpoint = exact_object(value, fields, "checkpoint") + if checkpoint["schema"] != CHECKPOINT_SCHEMA or checkpoint["authority"] != "advisory-projection": + fail("GOV-CONTINUITY-001", "checkpoint schema or authority is invalid") + reference(checkpoint["checkpointRef"], "checkpointRef", kind="receipt") + if checkpoint["previousCheckpointRef"] is not None: + reference(checkpoint["previousCheckpointRef"], "previousCheckpointRef", kind="receipt") + sequence = checkpoint["sequence"] + if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 1: + fail("GOV-CONTINUITY-001", "sequence must be a positive integer") + if (sequence == 1) != (checkpoint["previousCheckpointRef"] is None): + fail("GOV-CONTINUITY-002", "checkpoint sequence and previous reference do not agree") + repository = validate_repository(checkpoint["repositoryRef"], "repositoryRef") + if not isinstance(checkpoint["ticket"], str) or TICKET_RE.fullmatch(checkpoint["ticket"]) is None: + fail("GOV-CONTINUITY-001", "ticket is invalid") + if not isinstance(checkpoint["workstream"], str) or WORKSTREAM_RE.fullmatch(checkpoint["workstream"]) is None: + fail("GOV-CONTINUITY-001", "workstream is invalid") + reference(checkpoint["intentRef"], "intentRef", kind="artifact") + sha(checkpoint["intentSha256"], "intentSha256", SHA256_RE) + sha(checkpoint["scopeSha256"], "scopeSha256", SHA256_RE) + validate_binding(checkpoint["plan"], "plan") + validate_slice(checkpoint["slice"]) + git_ref(checkpoint["targetBranch"], "targetBranch") + git_ref(checkpoint["branchRef"], "branchRef") + sha(checkpoint["headSha"], "headSha", SHA1_RE) + safe_id(checkpoint["worktreeId"], "worktreeId") + if checkpoint["phase"] not in PHASES: + fail("GOV-CONTINUITY-001", "phase is invalid") + reference(checkpoint["authorizationRef"], "authorizationRef", kind="authorization") + validate_lease(checkpoint["lease"]) + validate_remote(checkpoint["remoteObservation"], repository) + validate_workspace(checkpoint["workspace"]) + validate_checkpoint_progress(checkpoint) + timestamp(checkpoint["recordedAt"]) + digest_payload = dict(checkpoint) + digest_payload.pop("checkpointRef") + expected_ref = f"receipt:continuity.{checkpoint['ticket']}.{sequence}.{canonical_digest(digest_payload)}" + if checkpoint["checkpointRef"] != expected_ref: + fail("GOV-CONTINUITY-002", "checkpoint reference does not bind its canonical content") + return checkpoint + + +def validate_event(value: Any) -> dict[str, Any]: + event = exact_object( + value, + {"schema", "eventRef", "previousEventRef", "eventSequence", "sessionId", "checkpoint"}, + "event", + ) + if event["schema"] != EVENT_SCHEMA: + fail("GOV-CONTINUITY-001", "event schema is invalid") + reference(event["eventRef"], "eventRef", kind="receipt") + if event["previousEventRef"] is not None: + reference(event["previousEventRef"], "previousEventRef", kind="receipt") + sequence = event["eventSequence"] + if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 1: + fail("GOV-CONTINUITY-001", "eventSequence must be a positive integer") + if (sequence == 1) != (event["previousEventRef"] is None): + fail("GOV-CONTINUITY-002", "event sequence and previous reference do not agree") + safe_id(event["sessionId"], "sessionId") + validate_checkpoint(event["checkpoint"]) + digest_payload = dict(event) + digest_payload.pop("eventRef") + expected = f"receipt:continuity-event.{event['sessionId']}.{sequence}.{canonical_digest(digest_payload)}" + if event["eventRef"] != expected: + fail("GOV-CONTINUITY-002", "event reference does not bind its canonical content") + return event + + +def validate_index(value: Any) -> dict[str, Any]: + index = exact_object(value, {"schema", "repositoryRef", "maxEntries", "entries", "updatedAt"}, "index") + if index["schema"] != INDEX_SCHEMA: + fail("GOV-CONTINUITY-001", "index schema is invalid") + validate_repository(index["repositoryRef"], "index.repositoryRef") + maximum = index["maxEntries"] + if not isinstance(maximum, int) or isinstance(maximum, bool) or not 1 <= maximum <= 128: + fail("GOV-CONTINUITY-001", "index maxEntries is invalid") + entries = index["entries"] + if not isinstance(entries, list) or len(entries) > maximum: + fail("GOV-CONTINUITY-001", "index exceeds its bounded entry limit") + tickets: set[str] = set() + for number, item in enumerate(entries): + entry = exact_object( + item, + {"ticket", "sessionId", "eventRef", "checkpointRef", "checkpointSequence", "recordedAt"}, + f"index.entries[{number}]", + ) + if not isinstance(entry["ticket"], str) or TICKET_RE.fullmatch(entry["ticket"]) is None: + fail("GOV-CONTINUITY-001", "index ticket is invalid") + if entry["ticket"] in tickets: + fail("GOV-CONTINUITY-002", "index contains duplicate tickets") + tickets.add(entry["ticket"]) + safe_id(entry["sessionId"], "index sessionId") + reference(entry["eventRef"], "index eventRef", kind="receipt") + reference(entry["checkpointRef"], "index checkpointRef", kind="receipt") + if (not isinstance(entry["checkpointSequence"], int) + or isinstance(entry["checkpointSequence"], bool) + or entry["checkpointSequence"] < 1): + fail("GOV-CONTINUITY-001", "index checkpointSequence is invalid") + timestamp(entry["recordedAt"], "index recordedAt") + timestamp(index["updatedAt"], "index updatedAt") + return index + + +def validate_document(value: Any) -> dict[str, Any]: + if isinstance(value, dict) and value.get("schema") == CHECKPOINT_SCHEMA: + return validate_checkpoint(value) + if isinstance(value, dict) and value.get("schema") == EVENT_SCHEMA: + return validate_event(value) + if isinstance(value, dict) and value.get("schema") == INDEX_SCHEMA: + return validate_index(value) + fail("GOV-CONTINUITY-001", "unsupported continuity schema") + + +def git(root: Path, *arguments: str, binary: bool = False, check: bool = True) -> str | bytes | None: + try: + result = subprocess.run( + ["git", "-C", str(root), *arguments], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except OSError as exc: + fail("GOV-CONTINUITY-003", f"cannot observe Git state for {arguments[0]}: {exc}") + if result.returncode != 0: + if not check: + return None + fail("GOV-CONTINUITY-003", f"cannot observe Git state for {arguments[0]}") + return result.stdout if binary else result.stdout.decode("utf-8").strip() + + +def repository_ref(root: Path) -> str: + value = git(root, "config", "--get", "remote.origin.url") + assert isinstance(value, str) + origin = string(value, "repository origin") + if "://" in origin: + parsed = urlsplit(origin) + if parsed.scheme not in {"git", "http", "https", "ssh"} or not parsed.hostname: + fail("GOV-CONTINUITY-001", "repository origin uses an unsupported transport") + try: + port = parsed.port + except ValueError: + fail("GOV-CONTINUITY-001", "repository origin has an invalid port") + host = parsed.hostname if port is None else f"{parsed.hostname}.port-{port}" + repository_path = parsed.path + else: + match = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", origin) + if match is None: + fail("GOV-CONTINUITY-001", "repository origin must name a remote host and path") + host, repository_path = match.groups() + repository_path = repository_path.lstrip("/") + if repository_path.endswith(".git"): + repository_path = repository_path[:-4] + return validate_repository(f"repository:{host.lower()}/{repository_path}", "repository origin") + + +def validate_manifest_value(value: Any) -> dict[str, Any]: + fields = {"schema", "standardPin", "ignoredRuntimeDirectories", "continuity"} + manifest = exact_object(value, fields, "subactor manifest") + if manifest["schema"] != MANIFEST_SCHEMA: + fail("GOV-CONTINUITY-001", "subactor manifest schema is invalid") + pin = exact_object(manifest["standardPin"], {"lockPath", "requiredStandardId"}, "standardPin") + if pin != { + "lockPath": ".governance/manifest.lock.json", + "requiredStandardId": "wellmanifest/new-project", + }: + fail("GOV-CONTINUITY-001", "standard pin contract is invalid") + if manifest["ignoredRuntimeDirectories"] != IGNORED_DIRECTORIES: + fail("GOV-CONTINUITY-001", "managed local-runtime directory list is invalid") + continuity = exact_object( + manifest["continuity"], + { + "checkpointSchema", "eventSchema", "indexSchema", "eventStreamPath", + "eventStreamPolicyMaxBytes", "checkpointIndexPath", "checkpointIndexMaxEntries", + "checkpointIndexMaxBytes", "checkpointIndexWrite", + }, + "continuity manifest", + ) + expected = { + "checkpointSchema": CHECKPOINT_SCHEMA, + "eventSchema": EVENT_SCHEMA, + "indexSchema": INDEX_SCHEMA, + "eventStreamPath": ".subactor/sessions/work-continuity.jsonl", + "eventStreamPolicyMaxBytes": None, + "checkpointIndexPath": ".subactor/recovery/checkpoint-index.json", + "checkpointIndexMaxEntries": 128, + "checkpointIndexMaxBytes": 262144, + "checkpointIndexWrite": "atomic-replace", + } + if continuity != expected: + fail("GOV-CONTINUITY-001", "continuity storage contract is invalid") + return manifest + + +def load_local_manifest(root: Path) -> dict[str, Any]: + return validate_manifest_value(load_json(root / ".subactor" / "manifest.json")) + + +def storage_paths(root: Path) -> tuple[Path, Path, int, int]: + continuity = load_local_manifest(root)["continuity"] + return ( + root / continuity["eventStreamPath"], + root / continuity["checkpointIndexPath"], + continuity["checkpointIndexMaxEntries"], + continuity["checkpointIndexMaxBytes"], + ) + + + +@contextmanager +def _posix_stream_descriptor(path: Path, append: bool): + no_follow = getattr(os, "O_NOFOLLOW", None) + directory_flag = getattr(os, "O_DIRECTORY", None) + if no_follow is None or directory_flag is None: + fail("GOV-CONTINUITY-001", "safe continuity directory opens are unavailable") + directory_flags = os.O_RDONLY | directory_flag | no_follow | getattr(os, "O_CLOEXEC", 0) + directory = os.open(path.anchor, directory_flags) + descriptor = None + try: + for part in path.parts[1:-1]: + try: + child = os.open(part, directory_flags, dir_fd=directory) + except FileNotFoundError: + if not append: + raise + try: + os.mkdir(part, mode=0o700, dir_fd=directory) + except FileExistsError: + pass # Reopen with no-follow; a concurrent link is never traversed. + child = os.open(part, directory_flags, dir_fd=directory) + os.close(directory) + directory = child + flags = (os.O_WRONLY | os.O_CREAT | os.O_APPEND) if append else os.O_RDONLY + flags |= no_follow | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0) + descriptor = os.open(path.name, flags, 0o600, dir_fd=directory) + yield descriptor + finally: + if descriptor is not None: + os.close(descriptor) + os.close(directory) + + +@contextmanager +def _windows_stream_descriptor(path: Path, append: bool, *, readwrite: bool = False): + # Open reparse points themselves. Held directory handles omit DELETE + # sharing, so parent names cannot be replaced during the file operation. + # https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew + import ctypes + from ctypes import wintypes + import msvcrt + if len(path.anchor) != 3 or path.anchor[1:] != ":\\": + fail("GOV-CONTINUITY-001", "continuity requires an absolute local drive path") + class FileInformation(ctypes.Structure): + _fields_ = [("attributes", wintypes.DWORD), ("creation", wintypes.FILETIME), + ("access", wintypes.FILETIME), ("write", wintypes.FILETIME), + ("volume", wintypes.DWORD), ("sizeHigh", wintypes.DWORD), + ("sizeLow", wintypes.DWORD), ("links", wintypes.DWORD), + ("indexHigh", wintypes.DWORD), ("indexLow", wintypes.DWORD)] + api = ctypes.WinDLL("kernel32", use_last_error=True) + api.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, + wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE] + api.CreateFileW.restype = wintypes.HANDLE + api.GetFileInformationByHandle.argtypes = [wintypes.HANDLE, ctypes.POINTER(FileInformation)] + api.GetFileInformationByHandle.restype = wintypes.BOOL + api.CloseHandle.argtypes = [wintypes.HANDLE] + api.CloseHandle.restype = wintypes.BOOL + invalid = ctypes.c_void_p(-1).value + handles = [] + descriptor = None + def open_handle(candidate, directory=False): + access = 0x80 if directory else ( + 0xC0000080 if readwrite else (0x40000080 if append else 0x80000000)) + flags = 0x00200000 | (0x02000000 if directory else 0) + creation = 4 if append and not directory else 3 # OPEN_ALWAYS / OPEN_EXISTING + handle = api.CreateFileW(str(candidate), access, 3, None, creation, flags, None) + if handle == invalid: + error = ctypes.get_last_error() + if error in (2, 3): + raise FileNotFoundError(errno.ENOENT, "continuity path is absent", str(candidate)) + raise ctypes.WinError(error) + handles.append(handle) + metadata = FileInformation() + if not api.GetFileInformationByHandle(handle, ctypes.byref(metadata)): + raise ctypes.WinError(ctypes.get_last_error()) + if metadata.attributes & 0x400 or bool(metadata.attributes & 0x10) != directory: + fail("GOV-CONTINUITY-001", "continuity paths must not be reparse points or unexpected types") + if not directory and metadata.links != 1: + fail("GOV-CONTINUITY-001", "continuity stream must be a single-linked regular file") + return handle + try: + open_handle(Path(path.anchor), directory=True) + current = Path(path.anchor) + for part in path.parts[1:-1]: + current /= part + try: + open_handle(current, directory=True) + except FileNotFoundError: + if not append: + raise + try: + current.mkdir(mode=0o700) + except FileExistsError: + pass + open_handle(current, directory=True) + handle = open_handle(path) + descriptor = msvcrt.open_osfhandle( + handle, os.O_BINARY | os.O_NOINHERIT | ( + os.O_RDWR if readwrite else (os.O_APPEND if append else os.O_RDONLY)) + ) + handles.pop() # The CRT descriptor now owns the file handle. + yield descriptor + finally: + if descriptor is not None: + os.close(descriptor) + for handle in reversed(handles): + api.CloseHandle(handle) + + +@contextmanager +def _stream_descriptor(path: Path, append: bool): + candidate = path.absolute() + if not candidate.name or ".." in candidate.parts: + fail("GOV-CONTINUITY-001", "continuity stream needs a confined file path") + adapter = _windows_stream_descriptor if os.name == "nt" else _posix_stream_descriptor + try: + with adapter(candidate, append) as descriptor: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + fail("GOV-CONTINUITY-001", "continuity stream must be a single-linked regular file") + yield descriptor + except FileNotFoundError: + if not append: + raise + fail("GOV-CONTINUITY-001", "continuity stream parent disappeared before creation") + except OSError as error: + fail("GOV-CONTINUITY-001", f"cannot safely access continuity stream: {error}") + + +def iter_events(path: Path) -> Iterable[dict[str, Any]]: + try: + with _stream_descriptor(path, append=False) as descriptor, os.fdopen(descriptor, "rb", closefd=False) as stream: + for number, raw in enumerate(stream, start=1): + if len(raw) > 1024 * 1024: + fail("GOV-CONTINUITY-001", f"event line {number} exceeds the bounded event size") + if not raw.endswith(b"\n"): + fail("GOV-CONTINUITY-002", f"event stream has an incomplete line at {number}") + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as exc: + fail("GOV-CONTINUITY-002", f"event stream line {number} is invalid: {exc}") + yield validate_event(value) + except FileNotFoundError: + return + except ContinuityError: + raise + except OSError as exc: + fail("GOV-CONTINUITY-001", f"cannot read event stream {path}: {exc}") + + +def event_state(events: Iterable[dict[str, Any]], repository: str) -> tuple[list[dict[str, Any]], dict[str, Any]]: + all_events: list[dict[str, Any]] = [] + latest_checkpoint: dict[str, dict[str, Any]] = {} + latest_session: dict[str, dict[str, Any]] = {} + refs: set[str] = set() + for event in events: + checkpoint = event["checkpoint"] + if checkpoint["repositoryRef"] != repository: + fail("GOV-CONTINUITY-002", "event belongs to another repository") + if event["eventRef"] in refs: + fail("GOV-CONTINUITY-002", "event reference is not append-only unique") + refs.add(event["eventRef"]) + prior_session = latest_session.get(event["sessionId"]) + expected_event_sequence = 1 if prior_session is None else prior_session["eventSequence"] + 1 + expected_event_previous = None if prior_session is None else prior_session["eventRef"] + if event["eventSequence"] != expected_event_sequence or event["previousEventRef"] != expected_event_previous: + fail("GOV-CONTINUITY-002", f"session chain for {event['sessionId']} is not monotonic") + prior_checkpoint = latest_checkpoint.get(checkpoint["ticket"]) + expected_sequence = 1 if prior_checkpoint is None else prior_checkpoint["sequence"] + 1 + expected_previous = None if prior_checkpoint is None else prior_checkpoint["checkpointRef"] + if checkpoint["sequence"] != expected_sequence or checkpoint["previousCheckpointRef"] != expected_previous: + fail("GOV-CONTINUITY-002", f"checkpoint chain for {checkpoint['ticket']} is not monotonic") + latest_session[event["sessionId"]] = event + latest_checkpoint[checkpoint["ticket"]] = checkpoint + all_events.append(event) + return all_events, {"sessions": latest_session, "checkpoints": latest_checkpoint} + + +def append_event(path: Path, event: dict[str, Any]) -> None: + payload = canonical_bytes(event) + b"\n" + with _stream_descriptor(path, append=True) as descriptor: + offset = 0 + while offset < len(payload): + offset += os.write(descriptor, payload[offset:]) + os.fsync(descriptor) + + +def index_from_events(repository: str, events: list[dict[str, Any]], maximum: int) -> dict[str, Any]: + latest: dict[str, dict[str, Any]] = {} + order: list[str] = [] + for event in events: + checkpoint = event["checkpoint"] + ticket = checkpoint["ticket"] + if ticket in order: + order.remove(ticket) + order.append(ticket) + latest[ticket] = { + "ticket": ticket, + "sessionId": event["sessionId"], + "eventRef": event["eventRef"], + "checkpointRef": checkpoint["checkpointRef"], + "checkpointSequence": checkpoint["sequence"], + "recordedAt": checkpoint["recordedAt"], + } + value = { + "schema": INDEX_SCHEMA, + "repositoryRef": repository, + "maxEntries": maximum, + "entries": [latest[ticket] for ticket in order[-maximum:]], + "updatedAt": utc_now(), + } + return validate_index(value) + + +def index_payload(value: dict[str, Any], maximum_bytes: int) -> bytes: + payload = json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False).encode("utf-8") + b"\n" + if len(payload) > maximum_bytes: + fail("GOV-CONTINUITY-001", "checkpoint index exceeds its bounded byte limit") + return payload + + +def write_index(path: Path, value: dict[str, Any], maximum_bytes: int) -> None: + payload = index_payload(value, maximum_bytes) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix="checkpoint-index.", suffix=".json", dir=path.parent) + temporary = Path(temporary_name) + try: + if hasattr(os, "fchmod"): + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + try: + directory_descriptor = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + except OSError: + pass + finally: + if temporary.exists(): + temporary.unlink() + + +@contextmanager +def _posix_lock_descriptor(path: Path): + """Anchor every parent and recheck public names after a potentially long wait.""" + no_follow = getattr(os, "O_NOFOLLOW", None) + directory_flag = getattr(os, "O_DIRECTORY", None) + if no_follow is None or directory_flag is None: + fail("GOV-CONTINUITY-001", "safe continuity lock directory opens are unavailable") + directory_flags = os.O_RDONLY | directory_flag | no_follow | getattr(os, "O_CLOEXEC", 0) + directories = [os.open(path.anchor, directory_flags)] + descriptor = None + try: + for part in path.parts[1:-1]: + try: + child = os.open(part, directory_flags, dir_fd=directories[-1]) + except FileNotFoundError: + try: + os.mkdir(part, mode=0o700, dir_fd=directories[-1]) + except FileExistsError: + pass + child = os.open(part, directory_flags, dir_fd=directories[-1]) + directories.append(child) + descriptor = os.open(path.name, os.O_RDWR | os.O_CREAT | no_follow + | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0), + 0o600, dir_fd=directories[-1]) + def verify_identity(): + # Held descriptors prevent traversal through a swapped link. They + # alone do not prove the public path still names the locked inode. + for parent, child, part in zip(directories, directories[1:], path.parts[1:-1]): + named = os.stat(part, dir_fd=parent, follow_symlinks=False) + held = os.fstat(child) + if not stat.S_ISDIR(named.st_mode) or (named.st_dev, named.st_ino) != (held.st_dev, held.st_ino): + fail("GOV-CONTINUITY-001", "continuity lock parent identity changed") + named = os.stat(path.name, dir_fd=directories[-1], follow_symlinks=False) + held = os.fstat(descriptor) + if (not stat.S_ISREG(named.st_mode) or named.st_nlink != 1 + or (named.st_dev, named.st_ino) != (held.st_dev, held.st_ino)): + fail("GOV-CONTINUITY-001", "continuity lock file identity changed") + yield descriptor, verify_identity + finally: + if descriptor is not None: + os.close(descriptor) + for directory in reversed(directories): + os.close(directory) + + +@contextmanager +def _lock_descriptor(path: Path): + with ExitStack() as handles: + try: + if os.name == "nt": + # No DELETE sharing on any held handle: Windows cannot rename + # the parent or lock while this transaction owns those handles. + descriptor = handles.enter_context(_windows_stream_descriptor(path, True, readwrite=True)) + verify = lambda: None + elif os.name == "posix": + descriptor, verify = handles.enter_context(_posix_lock_descriptor(path)) + else: + fail("GOV-CONTINUITY-001", "safe continuity transaction locking is unavailable") + except (OSError, NotImplementedError) as error: + raise ContinuityError("GOV-CONTINUITY-001", "continuity transaction lock path is unsafe or unavailable") from error + def verify_identity(): + try: + verify() + except (OSError, NotImplementedError) as error: + raise ContinuityError("GOV-CONTINUITY-001", "continuity transaction lock identity is unavailable") from error + # Exceptions from the transaction body keep their original meaning, + # notably interrupted journal/index writes which exact replay repairs. + yield descriptor, verify_identity + + +@contextmanager +def storage_transaction(event_path: Path): + """Stable OS-owned lock; never unlink it while another writer can use it.""" + if not event_path.name or ".." in event_path.absolute().parts: + fail("GOV-CONTINUITY-001", "continuity transaction needs a confined file path") + lock_path = event_path.with_name(event_path.name + ".lock").absolute() + with _lock_descriptor(lock_path) as (descriptor, verify_identity): + with _locked_transaction(descriptor, verify_identity): + yield + + +@contextmanager +def _locked_transaction(descriptor, verify_identity): + acquired = False + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + fail("GOV-CONTINUITY-001", "continuity transaction lock must be a regular private file") + if os.name == "nt": + import msvcrt + def acquire(): + os.lseek(descriptor, 0, os.SEEK_SET) + msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1) + def unlock(): + os.lseek(descriptor, 0, os.SEEK_SET) + msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1) + else: + import fcntl + def acquire(): + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + def unlock(): + fcntl.flock(descriptor, fcntl.LOCK_UN) + deadline = time.monotonic() + 30 + while True: + verify_identity() + try: + acquire() + acquired = True + break + except OSError as error: + if error.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}: + raise + if time.monotonic() >= deadline: + fail("GOV-CONTINUITY-001", "continuity transaction lock acquisition timed out") + time.sleep(0.05) + verify_identity() + yield + finally: + if acquired: + unlock() + + +def commit_event(root: Path, event: dict[str, Any]) -> dict[str, Any]: + repository = repository_ref(root) + event_path, index_path, maximum, maximum_bytes = storage_paths(root) + validate_event(event) + with storage_transaction(event_path): + events, _ = event_state(iter_events(event_path), repository) + matching = next((existing for existing in events if existing["eventRef"] == event["eventRef"]), None) + if matching is not None: + if matching != event: + fail("GOV-CONTINUITY-002", "event reference already binds different content") + # An append can survive a failed index replace. The journal is + # authoritative for this advisory projection; exact replay heals it. + write_index(index_path, index_from_events(repository, events, maximum), maximum_bytes) + return {"status": "already-recorded", "event": event} + candidate = events + [event] + event_state(candidate, repository) + index = index_from_events(repository, candidate, maximum) + index_payload(index, maximum_bytes) # Reject bounded-size failure before append. + append_event(event_path, event) + write_index(index_path, index, maximum_bytes) + return {"status": "recorded", "event": event} + + +def resolve_compact_target_branch(root: Path) -> str: + candidates = ( + root / ".governance" / "manifest.json", + root / ".governance" / "manifest.base.json", + root / "governance" / "manifest.hub.json", + ) + targets: list[str] = [] + for cand in candidates: + if cand.is_file(): + try: + manifest_data = json.loads(cand.read_text(encoding="utf-8")) + except Exception: + continue + delivery_cfg = manifest_data.get("delivery") + if isinstance(delivery_cfg, dict): + tb = delivery_cfg.get("targetBranches") + if isinstance(tb, list): + for b in tb: + if isinstance(b, str) and b and b not in targets: + targets.append(b) + rep_cfg = manifest_data.get("repositoryPolicy", {}).get("delivery", {}) + if isinstance(rep_cfg, dict): + tb = rep_cfg.get("targetBranches") + if isinstance(tb, list): + for b in tb: + if isinstance(b, str) and b and b not in targets: + targets.append(b) + if targets: + break + if len(targets) == 1: + return targets[0] + fail("GOV-CONTINUITY-001", "intent target branch must be a bounded non-empty string") + + +def intent_state(root: Path, ticket: str) -> tuple[dict[str, Any], str, str, str]: + path = root / "project" / ticket / "intent.json" + try: + storage = git(root, "config", "--local", "--default", "files", "--get", "new-project.ticketStorage") + if storage == "sqlite": + spec = importlib.util.spec_from_file_location("continuity_ticket_input", Path(__file__).with_name("ticket_input.py")) + module = importlib.util.module_from_spec(spec) + previous = sys.dont_write_bytecode + try: + sys.dont_write_bytecode = True + spec.loader.exec_module(module) + finally: + sys.dont_write_bytecode = previous + raw = module.read_file(root, ticket, "intent.json") + elif storage == "files": + raw = path.read_bytes() + else: + raise ValueError("unknown ticket storage mode") + value = json.loads(raw.decode("utf-8")) + except Exception: + fail("GOV-CONTINUITY-003", "cannot resolve active ticket intent from configured storage") + if not isinstance(value, dict) or value.get("ticket") != ticket: + fail("GOV-CONTINUITY-003", "ticket intent identity does not match") + workstream = value.get("workstream") + if not isinstance(workstream, str) or WORKSTREAM_RE.fullmatch(workstream) is None: + fail("GOV-CONTINUITY-003", "ticket intent workstream is invalid") + delivery = value.get("delivery") + target_branch = delivery.get("targetBranch") if isinstance(delivery, dict) else None + if target_branch is None: + target_branch = resolve_compact_target_branch(root) + git_ref(target_branch, "intent target branch") + scope = { + "ticket": value.get("ticket"), + "workstream": workstream, + "allowedPaths": value.get("allowedPaths"), + "forbiddenPaths": value.get("forbiddenPaths"), + "dependsOn": value.get("dependsOn"), + "conflictsWith": value.get("conflictsWith"), + "integrationTicket": value.get("integrationTicket"), + "targetBranch": target_branch, + } + return value, hashlib.sha256(raw).hexdigest(), canonical_digest(scope), target_branch + + +def parse_pending(value: str) -> dict[str, Any]: + fields = value.split(",", 3) + if len(fields) not in {3, 4}: + fail("GOV-CONTINUITY-001", "--pending expects kind,state,idempotencyKey[,effectRef]") + return { + "kind": fields[0], + "state": fields[1], + "idempotencyKey": fields[2], + "effectRef": fields[3] if len(fields) == 4 and fields[3] else None, + } + + +def capture_workspace(args, status_bytes): + snapshot_values = ( + args.snapshot_ref, args.snapshot_sha256, args.snapshot_receipt, + args.snapshot_secret_scan_receipt, + ) + status_digest = hashlib.sha256(status_bytes).hexdigest() + if status_bytes: + if not all(value is not None for value in snapshot_values): + fail( + "GOV-CONTINUITY-001", + "dirty workspace needs a content-addressed snapshot, snapshot receipt and secret-scan receipt", + ) + workspace = { + "state": "snapshotted", + "resumeSource": "snapshot", + "statusSha256": status_digest, + "snapshotRef": args.snapshot_ref, + "snapshotSha256": args.snapshot_sha256, + "snapshotReceipt": args.snapshot_receipt, + "secretScanReceipt": args.snapshot_secret_scan_receipt, + } + else: + if any(value is not None for value in snapshot_values): + fail("GOV-CONTINUITY-001", "clean workspace cannot claim a snapshot") + workspace = { + "state": "clean", "resumeSource": "commit", "statusSha256": EMPTY_SHA256, + "snapshotRef": None, "snapshotSha256": None, "snapshotReceipt": None, + "secretScanReceipt": None, + } + return workspace + + +def capture(args: argparse.Namespace) -> dict[str, Any]: + root = args.root.resolve() + repository = repository_ref(root) + event_path, _, _, _ = storage_paths(root) + _, state = event_state(iter_events(event_path), repository) + intent, intent_digest, scope_digest, target_branch = intent_state(root, args.ticket) + branch = git(root, "symbolic-ref", "--quiet", "--short", "HEAD") + head = git(root, "rev-parse", "HEAD") + status_bytes = git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all", binary=True) + assert isinstance(branch, str) and isinstance(head, str) and isinstance(status_bytes, bytes) + workspace = capture_workspace(args, status_bytes) + prior = state["checkpoints"].get(args.ticket) + sequence = 1 if prior is None else prior["sequence"] + 1 + lease = None + lease_values = (args.lease_ref, args.lease_revision, args.fencing_token) + if any(value is not None for value in lease_values): + if not all(value is not None for value in lease_values): + fail("GOV-CONTINUITY-001", "lease reference, revision and fencing token must be supplied together") + lease = { + "leaseRef": args.lease_ref, + "leaseRevision": args.lease_revision, + "fencingToken": args.fencing_token, + } + recorded_at = utc_now() + checkpoint: dict[str, Any] = { + "schema": CHECKPOINT_SCHEMA, + "authority": "advisory-projection", + "checkpointRef": "receipt:pending", + "previousCheckpointRef": None if prior is None else prior["checkpointRef"], + "sequence": sequence, + "repositoryRef": repository, + "ticket": args.ticket, + "workstream": intent["workstream"], + "intentRef": f"artifact:intent/{args.ticket}/{intent_digest}", + "intentSha256": intent_digest, + "scopeSha256": scope_digest, + "plan": {"ref": args.plan_ref, "sha256": args.plan_sha256}, + "slice": { + "ref": args.slice_ref, "sha256": args.slice_sha256, + "ordinal": args.slice_ordinal, "total": args.slice_total, + }, + "targetBranch": target_branch, + "branchRef": branch, + "headSha": head, + "worktreeId": args.worktree_id, + "phase": args.phase, + "authorizationRef": args.authorization_ref, + "lease": lease, + "remoteObservation": { + "remoteName": args.remote_name, + "repositoryRef": repository, + "accountRef": args.remote_account_ref, + "observedAt": recorded_at, + "receiptRef": args.remote_observation_receipt, + }, + "workspace": workspace, + "completedCriteria": args.completed, + "remainingCriteria": args.remaining, + "evidenceRefs": args.evidence, + "pendingEffects": [parse_pending(value) for value in args.pending], + "nextAction": {"kind": args.next_action, "criterion": args.next_criterion}, + "recordedAt": recorded_at, + } + digest_payload = dict(checkpoint) + digest_payload.pop("checkpointRef") + checkpoint["checkpointRef"] = f"receipt:continuity.{args.ticket}.{sequence}.{canonical_digest(digest_payload)}" + validate_checkpoint(checkpoint) + prior_event = state["sessions"].get(args.session_id) + event_sequence = 1 if prior_event is None else prior_event["eventSequence"] + 1 + event: dict[str, Any] = { + "schema": EVENT_SCHEMA, + "eventRef": "receipt:pending", + "previousEventRef": None if prior_event is None else prior_event["eventRef"], + "eventSequence": event_sequence, + "sessionId": args.session_id, + "checkpoint": checkpoint, + } + event_payload = dict(event) + event_payload.pop("eventRef") + event["eventRef"] = f"receipt:continuity-event.{args.session_id}.{event_sequence}.{canonical_digest(event_payload)}" + validate_event(event) + commit_event(root, event) + return event + + +def record(args: argparse.Namespace) -> dict[str, Any]: + root = args.root.resolve() + checkpoint = validate_checkpoint(load_json(args.checkpoint)) + repository = repository_ref(root) + if checkpoint["repositoryRef"] != repository: + fail("GOV-CONTINUITY-002", "checkpoint belongs to another repository") + event_path, _, _, _ = storage_paths(root) + _, state = event_state(iter_events(event_path), repository) + prior = state["sessions"].get(args.session_id) + event_sequence = 1 if prior is None else prior["eventSequence"] + 1 + event: dict[str, Any] = { + "schema": EVENT_SCHEMA, + "eventRef": "receipt:pending", + "previousEventRef": None if prior is None else prior["eventRef"], + "eventSequence": event_sequence, + "sessionId": args.session_id, + "checkpoint": checkpoint, + } + digest_payload = dict(event) + digest_payload.pop("eventRef") + event["eventRef"] = f"receipt:continuity-event.{args.session_id}.{event_sequence}.{canonical_digest(digest_payload)}" + validate_event(event) + return commit_event(root, event) + + +def resolve(args: argparse.Namespace) -> dict[str, Any]: + root = args.root.resolve() + repository = repository_ref(root) + event_path, _, _, _ = storage_paths(root) + _, state = event_state(iter_events(event_path), repository) + checkpoint = state["checkpoints"].get(args.ticket) + if checkpoint is None: + fail("GOV-CONTINUITY-002", f"no continuity checkpoint exists for {args.ticket}") + return checkpoint + + +def rebuild_index(args: argparse.Namespace) -> dict[str, Any]: + root = args.root.resolve() + repository = repository_ref(root) + event_path, index_path, maximum, maximum_bytes = storage_paths(root) + with storage_transaction(event_path): + events, _ = event_state(iter_events(event_path), repository) + index = index_from_events(repository, events, maximum) + write_index(index_path, index, maximum_bytes) + return {"status": "rebuilt", "entries": len(index["entries"]), "index": str(index_path.relative_to(root))} + + +def verify_checkpoint(root: Path, checkpoint: dict[str, Any]) -> dict[str, Any]: + observations: dict[str, Any] = { + "repositoryRef": repository_ref(root), + "branchRef": git(root, "symbolic-ref", "--quiet", "--short", "HEAD"), + "headSha": git(root, "rev-parse", "HEAD"), + } + _, intent_digest, scope_digest, target_branch = intent_state(root, checkpoint["ticket"]) + observations.update({ + "intentSha256": intent_digest, + "scopeSha256": scope_digest, + "targetBranch": target_branch, + }) + status_bytes = git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all", binary=True) + assert isinstance(status_bytes, bytes) + observations["statusSha256"] = hashlib.sha256(status_bytes).hexdigest() + expected = { + "repositoryRef": checkpoint["repositoryRef"], + "branchRef": checkpoint["branchRef"], + "headSha": checkpoint["headSha"], + "intentSha256": checkpoint["intentSha256"], + "scopeSha256": checkpoint["scopeSha256"], + "targetBranch": checkpoint["targetBranch"], + "statusSha256": checkpoint["workspace"]["statusSha256"], + } + mismatches = { + field: {"expected": expected[field], "observed": observations[field]} + for field in expected if expected[field] != observations[field] + } + if mismatches: + fail("GOV-CONTINUITY-003", "checkpoint diverges from current repository observation: " + ", ".join(mismatches)) + return { + "status": "matches-observed-state", + "checkpointRef": checkpoint["checkpointRef"], + "authority": "advisory-projection", + "authorityVerified": False, + "leaseMustBeRevalidated": checkpoint["lease"] is not None, + "remoteAccountMustBeReobserved": True, + } + + +def verify(args: argparse.Namespace) -> dict[str, Any]: + root = args.root.resolve() + checkpoint = validate_checkpoint(load_json(args.checkpoint)) if args.checkpoint else resolve(args) + return verify_checkpoint(root, checkpoint) + + +def candidate_bytes(root: Path, relative: str, staged: bool) -> bytes | None: + if not staged: + try: + return (root / relative).read_bytes() + except FileNotFoundError: + return None + except OSError as exc: + fail("GOV-CONTINUITY-001", f"cannot read local pin file {relative}: {exc}") + value = git(root, "show", f":{relative}", binary=True, check=False) + return value if isinstance(value, bytes) else None + + +def validate_adoption_pin(manifest, lock): + pin = manifest["standardPin"] + standard = lock.get("standard") if isinstance(lock, dict) else None + if not isinstance(standard, dict): + fail("GOV-CONTINUITY-001", "adoption lock has no standard pin") + expected_fields = {"id", "version", "sourceRepository", "sourceRevision", "publicationStatus"} + if set(standard) != expected_fields: + fail("GOV-CONTINUITY-001", "adoption lock standard pin fields are invalid") + if ( + standard["id"] != pin["requiredStandardId"] + or standard["sourceRepository"] != "wellmanifest/new-project" + or not isinstance(standard["version"], str) + or VERSION_RE.fullmatch(standard["version"]) is None + or not isinstance(standard["sourceRevision"], str) + or SHA1_RE.fullmatch(standard["sourceRevision"]) is None + or standard["publicationStatus"] not in {"published", "unpublished-test"} + ): + fail("GOV-CONTINUITY-001", "adoption lock standard pin is invalid") + return standard + + +def verify_pin(args: argparse.Namespace) -> dict[str, Any]: + root = args.root.resolve() + manifest_relative = ".subactor/manifest.json" + lock_relative = ".governance/manifest.lock.json" + manifest_raw = candidate_bytes(root, manifest_relative, args.staged) + lock_raw = candidate_bytes(root, lock_relative, args.staged) + if manifest_raw is None and lock_raw is None: + return {"status": "not-applicable", "networkAccess": False, "mutated": False} + if manifest_raw is None or lock_raw is None: + fail("GOV-CONTINUITY-001", "local standard pin requires both manifest and adoption lock") + try: + manifest = json.loads(manifest_raw.decode("utf-8")) + lock = json.loads(lock_raw.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as exc: + fail("GOV-CONTINUITY-001", f"local standard pin is unreadable: {exc}") + try: + validate_manifest_value(manifest) + except ContinuityError as exc: + fail("GOV-CONTINUITY-001", f"local Subactor manifest drifted: {exc}") + standard = validate_adoption_pin(manifest, lock) + managed = lock.get("managedFiles") + if not isinstance(managed, dict): + fail("GOV-CONTINUITY-001", "adoption lock has no managed file map") + if managed.get(manifest_relative) != hashlib.sha256(manifest_raw).hexdigest(): + fail("GOV-CONTINUITY-001", "tracked Subactor manifest does not match the local immutable pin") + ignore_relative = ".subactor/.gitignore" + ignore_raw = candidate_bytes(root, ignore_relative, args.staged) + if ignore_raw is None or managed.get(ignore_relative) != hashlib.sha256(ignore_raw).hexdigest(): + fail("GOV-CONTINUITY-001", "managed Subactor ignore rules do not match the local immutable pin") + return { + "status": "locally-pinned", + "standard": standard["id"], + "version": standard["version"], + "sourceRevision": standard["sourceRevision"], + "networkAccess": False, + "mutated": False, + } + + +def add_capture_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--ticket", required=True) + parser.add_argument("--session-id", required=True) + parser.add_argument("--phase", choices=sorted(PHASES), required=True) + parser.add_argument("--worktree-id", required=True) + parser.add_argument("--authorization-ref", required=True) + parser.add_argument("--plan-ref", required=True) + parser.add_argument("--plan-sha256", required=True) + parser.add_argument("--slice-ref", required=True) + parser.add_argument("--slice-sha256", required=True) + parser.add_argument("--slice-ordinal", type=int, required=True) + parser.add_argument("--slice-total", type=int, required=True) + parser.add_argument("--remote-name", default="origin") + parser.add_argument("--remote-account-ref", required=True) + parser.add_argument("--remote-observation-receipt", required=True) + parser.add_argument("--lease-ref") + parser.add_argument("--lease-revision", type=int) + parser.add_argument("--fencing-token", type=int) + parser.add_argument("--snapshot-ref") + parser.add_argument("--snapshot-sha256") + parser.add_argument("--snapshot-receipt") + parser.add_argument("--snapshot-secret-scan-receipt") + parser.add_argument("--completed", action="append", default=[]) + parser.add_argument("--remaining", action="append", default=[]) + parser.add_argument("--evidence", action="append", default=[]) + parser.add_argument("--pending", action="append", default=[]) + parser.add_argument("--next-action", choices=sorted(NEXT_ACTIONS), required=True) + parser.add_argument("--next-criterion") + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + subparsers = result.add_subparsers(dest="command", required=True) + validate_parser = subparsers.add_parser("validate", help="validate a v2 checkpoint, event or index") + validate_parser.add_argument("document", type=Path) + capture_parser = subparsers.add_parser("capture", help="append the current repository checkpoint event") + add_capture_arguments(capture_parser) + record_parser = subparsers.add_parser("record", help="append an externally restored checkpoint event") + record_parser.add_argument("--root", type=Path, default=Path.cwd()) + record_parser.add_argument("--checkpoint", type=Path, required=True) + record_parser.add_argument("--session-id", required=True) + resolve_parser = subparsers.add_parser("resolve", help="resolve the latest checkpoint for a ticket") + resolve_parser.add_argument("--root", type=Path, default=Path.cwd()) + resolve_parser.add_argument("--ticket", required=True) + verify_parser = subparsers.add_parser("verify", help="compare a checkpoint with current observable state") + verify_parser.add_argument("--root", type=Path, default=Path.cwd()) + source = verify_parser.add_mutually_exclusive_group(required=True) + source.add_argument("--checkpoint", type=Path) + source.add_argument("--ticket") + rebuild_parser = subparsers.add_parser("rebuild-index", help="atomically rebuild the bounded index") + rebuild_parser.add_argument("--root", type=Path, default=Path.cwd()) + pin_parser = subparsers.add_parser("verify-pin", help="validate only the local immutable standard pin") + pin_parser.add_argument("--root", type=Path, default=Path.cwd()) + pin_parser.add_argument("--staged", action="store_true", help="read managed candidates from the Git index") + return result + + +def main() -> int: + args = parser().parse_args() + try: + if args.command == "validate": + document = validate_document(load_json(args.document)) + output: Any = {"status": "valid", "schema": document["schema"]} + elif args.command == "capture": + output = capture(args) + elif args.command == "record": + output = record(args) + elif args.command == "resolve": + output = resolve(args) + elif args.command == "verify": + output = verify(args) + elif args.command == "rebuild-index": + output = rebuild_index(args) + else: + output = verify_pin(args) + print(json.dumps(output, indent=2, sort_keys=True, ensure_ascii=False)) + return 0 + except ContinuityError as exc: + print(f"{exc.code}: {exc}", file=sys.stderr) + return 3 if exc.code == "GOV-CONTINUITY-003" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/work_start_check.py b/.governance/work_start_check.py new file mode 100755 index 0000000..0035826 --- /dev/null +++ b/.governance/work_start_check.py @@ -0,0 +1,1032 @@ +#!/usr/bin/env python3 +"""Read-only, clone-local work admission. Recommendations never grant authority.""" +from __future__ import annotations + +import argparse +from contextlib import ExitStack +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import traceback + +sys.dont_write_bytecode = True +from ticket_activity import ActivityError, delivery_landed, resolve as resolve_activity +from ticket_input import configured_mode, load_input, primary_database +from worktree_overlap_check import globs_may_overlap, path_ignored + +SCHEMA = "new-project.work-start-report/v1" +CODE = "GOV-WORK-START-001" +TICKET = re.compile(r"^ticket[/-]([0-9]{3,})(?:[-/].*)?$") +TRACKING = ("project/ticket-*/**", "project/TICKETS.md", "TODO.md") + + +class ObservationError(ValueError): + pass + + +class UnknownWorkstream(ObservationError): + """The requested workstream is not declared; a caller input error, not divergent local work.""" + + def __init__(self, declared): + super().__init__("Workstream is not declared in the governance manifest") + self.declared = declared + + +_OBSERVATION_LOG = ".subactor/receipts/work-start/observation-failures.log" +_OBSERVATION_LOG_MAX_BYTES = 128 * 1024 + + +def _record_observation_failure(root, error): + """Best-effort private, bounded diagnostics; never dirty or follow links.""" + try: + import fcntl + + root = Path(root).absolute() + # Do not infer ignore policy, or mutate a tracked diagnostic. + if git(root, "check-ignore", "--quiet", "--", _OBSERVATION_LOG, + optional=True) is None: + return False + if git(root, "ls-files", "--error-unmatch", "--", _OBSERVATION_LOG, + optional=True) is not None: + return False + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + with ExitStack() as stack: + directory = os.open(root.anchor, flags) + stack.callback(os.close, directory) + for part in root.parts[1:]: + directory = os.open(part, flags, dir_fd=directory) + stack.callback(os.close, directory) + for part in Path(_OBSERVATION_LOG).parent.parts: + try: + os.mkdir(part, 0o700, dir_fd=directory) + except FileExistsError: + pass + directory = os.open(part, flags, dir_fd=directory) + stack.callback(os.close, directory) + info = os.fstat(directory) + if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) & 0o077: + return False + fd = os.open(Path(_OBSERVATION_LOG).name, + os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, + 0o600, dir_fd=directory) + stack.callback(os.close, fd) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + info = os.fstat(fd) + if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 + or info.st_uid != os.geteuid() + or stat.S_IMODE(info.st_mode) & 0o077 + or info.st_size > _OBSERVATION_LOG_MAX_BYTES): + return False + previous = os.read(fd, _OBSERVATION_LOG_MAX_BYTES).decode("utf-8", "replace") + entry = (f"{datetime.now(timezone.utc).isoformat()} " + f"{type(error).__name__}: {str(error)[:8192]}\n" + f"{traceback.format_exc()[-65536:]}\n") + bounded = "".join((previous + entry).splitlines(keepends=True)[-2000:]) + data = bounded.encode("utf-8", "replace")[-_OBSERVATION_LOG_MAX_BYTES:] + data = data.decode("utf-8", "ignore").encode("utf-8") + os.lseek(fd, 0, os.SEEK_SET) + view = memoryview(data) + while view: + count = os.write(fd, view) + if count <= 0: + raise OSError("Incomplete diagnostic write") + view = view[count:] + os.ftruncate(fd, len(data)) + return True + except (OSError, ObservationError, ImportError, AttributeError, UnicodeError, NotImplementedError): + return False + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=True).encode()).hexdigest() + + +def git(root, *args, optional=False): + env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env["GIT_OPTIONAL_LOCKS"] = "0" + env["GIT_NO_REPLACE_OBJECTS"] = "1" + env["GIT_NO_LAZY_FETCH"] = "1" + env["GIT_TERMINAL_PROMPT"] = "0" + try: + result = subprocess.run(["git", "-C", str(root), *args], env=env, + capture_output=True, timeout=20) + except (OSError, subprocess.TimeoutExpired) as error: + raise ObservationError("Git observation unavailable") from error + if result.returncode: + if optional: + return None + raise ObservationError("Git observation failed: " + args[0]) + return result.stdout.decode("utf-8", "surrogateescape") + + +def read_json(path): + if path.is_symlink(): + raise ObservationError("Symlinked governance input") + try: + return json.loads(path.read_text()) + except (OSError, ValueError) as error: + raise ObservationError("Missing or invalid governance input") from error + + +def quarantined(path, primary): + """Checkouts parked under the repository's .worktrees/.quarantine/ directory are preserved evidence, not claims.""" + resolved = Path(path).resolve() + quarantine_root = (Path(primary) / ".worktrees" / ".quarantine").resolve() + return resolved != quarantine_root and resolved.is_relative_to(quarantine_root) + + +def _historical_inactive_logs(root, ticket_dir, key, status, dirty_paths, target_sha): + """Recognize unchanged inactive legacy prose, never a writer or allocation. + + New/modified contracts, existing invalid intents and branch-owned material + work retain the normal conservative checks. Only an identical README on + the observed target and direct diagnostic log files qualify. + """ + if status not in {"BACKLOG", "PLAN", "BLOCKED"} or ticket_dir.is_symlink(): + return False + intent = ticket_dir / "intent.json" + readme = ticket_dir / "README.md" + if intent.exists() or intent.is_symlink() or readme.is_symlink() or not readme.is_file(): + return False + prefix = "project/" + key + "/" + changed = [name for name in dirty_paths if name.startswith(prefix)] + if not changed or any(Path(name).parent.as_posix() != prefix.rstrip("/") + or not Path(name).name.endswith(".log.md") + or (root / name).is_symlink() or not (root / name).is_file() + for name in changed): + return False + committed = git(root, "show", target_sha + ":" + prefix + "README.md", optional=True) + return committed is not None and readme.read_bytes() == committed.encode("utf-8", "surrogateescape") + + +def manifest_at(root): + for rel in (".governance/manifest.json", ".governance/manifest.base.json", + "governance/manifest.hub.json"): + path = root / rel + if path.exists(): + manifest = read_json(path) + if manifest.get("schema") != "new-project.governance/v2": + raise ObservationError("Unsupported governance manifest") + return manifest + raise ObservationError("Governance manifest missing") + + +def patterns(values): + if (not isinstance(values, list) or not values or + any(not isinstance(p, str) or not p or p.startswith(("/", "!")) or + ".." in p.split("/") or "\\" in p or ":" in p or + any(ord(c) < 32 for c in p) for p in values)): + raise ObservationError("Expected nonempty repository-relative path patterns") + return sorted(set(values)) + + +def material(values): + return [p for p in values if not path_ignored(p, TRACKING)] + + +def intersects(left, right): + return any(globs_may_overlap(a, b) for a in left for b in right) + + +def unchanged_committed_legacy_carriers(root, head): + """Observe old file carriers without assigning activity or ownership.""" + if (root / "project").is_symlink(): + raise ObservationError("Symlinked legacy project directory") + entries = git(root, "ls-tree", "-r", "-z", head, "--", "project").split("\0") + committed = {} + directories = set() + for entry in filter(None, entries): + metadata, rel = entry.split("\t", 1) + if not rel.startswith("project/ticket-"): + continue + mode, kind, oid = metadata.split() + if (not re.fullmatch(r"project/ticket-[0-9]{3,}/.+", rel) + or mode not in {"100644", "100755"} or kind != "blob"): + raise ObservationError("Unsupported committed legacy carrier") + committed[rel] = oid + parent = Path(rel).parent + while parent != Path("project"): + directories.add(parent.as_posix()) + parent = parent.parent + observed = set() + observed_directories = set() + for ticket in (root / "project").glob("ticket-*"): + for path in (ticket, *ticket.rglob("*")): + if path.is_symlink(): + raise ObservationError("Symlinked legacy carrier") + rel = path.relative_to(root).as_posix() + if path.is_dir(): + observed_directories.add(rel) + elif path.is_file(): + observed.add(rel) + else: + raise ObservationError("Unsupported legacy carrier") + if observed != set(committed) or observed_directories != directories: + raise ObservationError("Legacy carriers differ from the committed bootstrap base") + for rel, oid in committed.items(): + original = git(root, "cat-file", "blob", oid).encode("utf-8", "surrogateescape") + if (root / rel).read_bytes() != original: + raise ObservationError("Changed legacy carrier requires owner reconciliation") + + +def bootstrap_historical_refs(root, head): + """Observe integrated history only; preserve refs and independent ownership.""" + refs = git(root, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads/ticket/") + if not refs: + return refs + if git(root, "rev-parse", "--is-shallow-repository").strip() != "false": + raise ObservationError("Bootstrap history is incomplete") + names = set() + for line in refs.splitlines(): + ref, sha = line.split(" ", 1) + # Equality is ambiguous: a newly reserved writer may still be at base. + # A checked merge-base also refuses missing/unreadable ancestry. + if sha == head or git(root, "merge-base", head, sha).strip() != sha: + raise ObservationError("Bootstrap ticket ref is not strictly integrated") + names.add(ref) + leases = root / ".subactor/leases" + if leases.is_symlink() or (leases.exists() and not leases.is_dir()): + raise ObservationError("Bootstrap lease observation unavailable") + for path in leases.glob("*.json"): + lease = read_json(path) + if lease.get("branchRef") not in names: + continue + try: + expiry = datetime.fromisoformat(lease["expiresAt"].replace("Z", "+00:00")) + if expiry.tzinfo is None: + raise ValueError("unbound expiry") + except (KeyError, TypeError, ValueError, AttributeError) as error: + raise ObservationError("Bootstrap lease expiry unavailable") from error + if expiry > datetime.now(timezone.utc): + raise ObservationError("Bootstrap historical ref retains a live lease") + return refs + + +def bootstrap_owned_paths(root, manifest, expected_digest): + """A first allocator may acknowledge only exact installer-owned bytes. + + This is the same explicit owner confirmation as continuation's dirty + digest. It suppresses no branch, ticket, WIP, lease or publication check. + """ + _, current_digest, dirty = dirty_observation(root) + if current_digest != expected_digest: + raise ObservationError("Bootstrap dirty observation changed") + receipt_path = root / ".subactor/receipts/bootstrap-adoption.json" + if any(path.is_symlink() for path in (receipt_path, *receipt_path.parents)): + raise ObservationError("Symlinked bootstrap receipt") + receipt = read_json(receipt_path) + head = git(root, "rev-parse", "HEAD").strip() + common = git(root, "rev-parse", "--path-format=absolute", "--git-common-dir").strip() + if (receipt.get("schema") != "new-project.bootstrap-adoption/v1" + or receipt.get("baseSha") != head or receipt.get("commonGitDir") != common + or receipt.get("grantsWriterAuthority") is not False + or receipt.get("grantsPublicationAuthority") is not False): + raise ObservationError("Bootstrap receipt bindings are invalid") + kind = receipt.get("bootstrapKind", "legacy-scaffold") + if kind == "fresh": + for name in ("manifest.json", "manifest.lock.json", "manifest.base.json", "package-manifest.json"): + if git(root, "cat-file", "-e", f"{head}:.governance/{name}", optional=True) is not None: + raise ObservationError("Fresh bootstrap cannot replace committed governance") + elif kind == "legacy-scaffold": + original = json.loads(git(root, "show", f"{head}:.governance/manifest.json")) + standard = original.get("standard", {}) + if (set(original) != {"schema", "standard"} + or original.get("schema") != "wellmanifest.manifest/v1" + or set(standard) != {"id", "version"} + or standard.get("id") != "profile:baseline" + or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", str(standard.get("version", "")))): + raise ObservationError("Bootstrap requires a committed exact legacy scaffold") + else: + raise ObservationError("Unknown bootstrap kind") + if (len(worktrees(root)) != 1 + or (Path(common) / "new-project-ticket-high-water").exists()): + raise ObservationError("Bootstrap acknowledgement is first-allocation only") + historical_refs = bootstrap_historical_refs(root, head) + if kind == "fresh": + unchanged_committed_legacy_carriers(root, head) + elif list((root / "project").glob("ticket-*")): + raise ObservationError("Scaffold bootstrap acknowledgement is first-allocation only") + lock = read_json(root / ".governance/manifest.lock.json") + if (lock.get("standard", {}).get("publicationStatus") != "published" + or lock.get("standard", {}).get("sourceRevision") != receipt.get("sourceRevision")): + raise ObservationError("Bootstrap requires a published adoption lock") + package = read_json(root / ".governance/package-manifest.json") + admissible = {item["target"] for item in package["files"]} + admissible.update({".gitignore", ".governance/manifest.lock.json"}) + for rel in manifest.get("requiredFiles", []): + if git(root, "cat-file", "-e", f"{head}:{rel}", optional=True) is None: + admissible.add(rel) + files = receipt.get("files") + if not isinstance(files, dict) or not files or set(files) - admissible: + raise ObservationError("Bootstrap receipt includes paths outside adoption") + if not set(lock["managedFiles"]).issubset(files): + raise ObservationError("Bootstrap receipt omits managed files") + for rel, checksum in files.items(): + patterns([rel]) + path = root / rel + if (any(p.is_symlink() for p in (path, *path.parents)) or not path.is_file() + or not re.fullmatch(r"[0-9a-f]{64}", str(checksum)) + or hashlib.sha256(path.read_bytes()).hexdigest() != checksum + or (rel in lock["managedFiles"] and lock["managedFiles"][rel] != checksum)): + raise ObservationError("Bootstrap payload changed or is not installed") + if (git(root, "rev-parse", "HEAD").strip() != head + or bootstrap_historical_refs(root, head) != historical_refs + or len(worktrees(root)) != 1 + or (Path(common) / "new-project-ticket-high-water").exists()): + raise ObservationError("Bootstrap allocation state changed during observation") + return set(dirty).intersection(files) + + +def bootstrap_seed_paths(root, manifest): + """A scope query may observe only exact first-installer required seeds. + + The allocator still requires its explicit dirty acknowledgement afterwards; + this query changes no intent, ownership, reservation or authority. + """ + root = Path(root).resolve() + if root != Path(worktrees(root)[0]["worktree"]).resolve(): + return set() + _, checksum, _ = dirty_observation(root) + owned = bootstrap_owned_paths(root, manifest, checksum) + from governance_check import initial_adoption_seed_paths + lock = read_json(root / ".governance/manifest.lock.json") + try: + return owned & initial_adoption_seed_paths( + root, git(root, "rev-parse", "HEAD").strip(), + lock["standard"]["sourceRevision"], manifest) + except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as error: + raise ObservationError("Bootstrap required seed evidence unavailable") from error + + +def changes(root, base, head): + ancestor = git(root, "merge-base", base, head, optional=True) + if not ancestor: + raise ObservationError("Unknown or unrelated branch ancestry") + output = git(root, "diff", "--no-ext-diff", "--no-textconv", "--no-renames", + "--name-only", "-z", ancestor.strip(), head) + return material([p for p in output.split("\0") if p]) + + +def worktrees(root): + output = git(root, "worktree", "list", "--porcelain", "-z") + result, item = [], {} + for field in output.split("\0"): + if not field: + if item: + result.append(item) + item = {} + else: + key, _, value = field.partition(" ") + item[key] = value + if item: + result.append(item) + if not result or "worktree" not in result[0] or "bare" in result[0]: + raise ObservationError("Registered primary checkout unavailable") + return result + + +def commit_trees(root, revision, *, ancestry_path=False): + """Complete immutable snapshots, never path similarity or patch IDs.""" + options = ("--ancestry-path",) if ancestry_path else () + output = git(root, "log", "--format=%T", "--no-show-signature", *options, revision) + trees = set(output.splitlines()) + if not trees or any(not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", tree) + for tree in trees): + raise ObservationError("Commit tree history unavailable") + return trees + + +def dirty_observation(root): + status = git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all") + fields = iter(status.split("\0")) + paths = set() + for field in fields: + if not field: + continue + paths.add(field[3:]) + if "R" in field[:2] or "C" in field[:2]: + paths.add(next(fields)) + # Bind bytes, not just status letters: editing an already dirty file must + # invalidate the observation. Never expose file content in the report. + hashes = {} + for rel in sorted(paths): + path = root / rel + if path.is_symlink(): + hashes[rel] = digest({"symlink": os.readlink(path)}) + elif path.is_file(): + with path.open("rb") as stream: + checksum = hashlib.sha256() + for chunk in iter(lambda: stream.read(65536), b""): + checksum.update(chunk) + hashes[rel] = checksum.hexdigest() + else: + hashes[rel] = "absent-or-submodule" + index = git(root, "ls-files", "--stage", "-z") + return material(sorted(paths)), digest({"status": status, "files": hashes, "index": index}), sorted(paths) + + +def outstanding_detached_paths(root, paths, target): + """Subtract exact integrated bytes, without modifying or releasing the checkout.""" + trees = {} + for item in git(root, "ls-tree", "-r", "--full-tree", "-z", target).split("\0"): + if item: + metadata, name = item.split("\t", 1) + mode, kind, oid = metadata.split() + if kind == "blob" and mode in {"100644", "100755"}: + trees[name] = (mode, oid) + indexes = {} + for item in git(root, "ls-files", "--stage", "-z").split("\0"): + if item: + metadata, name = item.split("\t", 1) + mode, oid, stage = metadata.split() + indexes.setdefault(name, []).append((mode, oid, stage)) + outstanding = [] + for name in paths: + path = root / name + accepted = trees.get(name) + if (not accepted or indexes.get(name) != [(*accepted, "0")] + or any(p.is_symlink() for p in (path, *path.parents)) or not path.is_file()): + outstanding.append(name) + continue + mode = "100755" if path.stat().st_mode & 0o100 else "100644" + # Raw hashing deliberately ignores clean/textconv filters and index + # flags: assume-unchanged cannot conceal different filesystem bytes. + oid = git(root, "hash-object", "--no-filters", "--", name).strip() + if (mode, oid) != accepted: + outstanding.append(name) + return outstanding + + +def dirty_modified(root, paths): + """Newest modification time of dirty paths: a recency observation, never writer identity.""" + newest = None + for rel in paths: + try: + stamp = (root / rel).lstat().st_mtime + except OSError: + continue + newest = stamp if newest is None or stamp > newest else newest + return None if newest is None else datetime.fromtimestamp(newest, timezone.utc).isoformat(timespec="seconds") + + +def landed(path, ticket, target): + """Whether the ticket directory is on the observed target and no ticket branch is outside it.""" + try: + return delivery_landed(path, path / "project" / ticket, target) + except subprocess.SubprocessError as error: + raise ObservationError("Target ancestry observation failed") from error + + +def remote_heads(root): + """Read advertisements, never fetch or print URLs/credential diagnostics.""" + result = {} + for line in git(root, "ls-remote", "--heads", "origin").splitlines(): + fields = line.split("\t") + if (len(fields) != 2 or + not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", fields[0]) or + not fields[1].startswith("refs/heads/") or fields[1] in result or + git(root, "check-ref-format", fields[1], optional=True) is None): + raise ObservationError("Invalid remote advertisement") + result[fields[1]] = fields[0] + return result + + +def publication_observation(root, entries, target): + """Evidence for a UI/CLI, not push permission or protected merge proof.""" + observation = { + "schema": "new-project.publication-observation/v1", + "observedAt": datetime.now(timezone.utc).isoformat(), + "readOnly": True, "grantsAuthority": False, "remote": "origin", + "scope": "origin-heads", + "status": "unavailable", "remoteRefsDigest": None, + "targetRef": "refs/heads/" + target, + "notObservedStages": ["pull-request", "checks", "approval", + "protected-merge", "release", "deployment"], + "worktrees": [], + } + + def unknown(): + observation["remoteRefsDigest"] = None + observation["worktrees"] = [ + {"path": e["path"], "branch": e["branch"], "headSha": e["headSha"], + "uncommittedPathCount": len(e["allDirtyPaths"]), + "remoteContainingRefs": [], "unpublishedCommitCount": None, + "sameBranchContainsHead": None, "headReachableFromTarget": None, + "nextAction": "observe-remote"} for e in entries] + return observation + + try: + before = remote_heads(root) + shallow = git(root, "rev-parse", "--is-shallow-repository").strip() + if shallow not in {"true", "false"}: + raise ObservationError("Shallow history observation unavailable") + shallow = shallow == "true" + known = {sha for sha in before.values() + if git(root, "cat-file", "-e", sha + "^{commit}", optional=True) is not None} + unknown_objects = set(before.values()) - known + observation["status"] = "partial" if unknown_objects or shallow else "observed" + observation["remoteRefsDigest"] = digest(before) + containment = {} + + def contains(head, remote_sha): + if remote_sha == head: + return True + if remote_sha not in known: + return None + # rev-list errors are unavailable evidence, not a negative proof. + key = (head, remote_sha) + if key not in containment: + count = int(git(root, "rev-list", "--count", head, "--not", remote_sha).strip()) + containment[key] = True if count == 0 else None if shallow else False + return containment[key] + + for entry in entries: + head = entry["headSha"] + refs = sorted(ref for ref, sha in before.items() if contains(head, sha) is True) + count = 0 if refs else None + if count is None and not unknown_objects and not shallow: + count = int(git(root, "rev-list", "--count", head, "--not", *sorted(known)).strip()) + branch_sha = before.get(entry["branch"]) + branch_contains = contains(head, branch_sha) if branch_sha else False + target_sha = before.get(observation["targetRef"]) + target_contains = contains(head, target_sha) if target_sha else None + dirty_count = len(entry["allDirtyPaths"]) + if dirty_count: + action = "preserve-local-work" + elif count is None: + action = "observe-remote" + elif count: + action = "review-push-preconditions" + elif branch_contains is not True: + action = "reconcile-branch-binding" + elif target_contains is not True: + action = "observe-integration-evidence" + else: + action = "observe-review-release-deployment" + observation["worktrees"].append({ + "path": entry["path"], "branch": entry["branch"], "headSha": head, + "uncommittedPathCount": dirty_count, "remoteContainingRefs": refs, + "unpublishedCommitCount": count, "sameBranchContainsHead": branch_contains, + "headReachableFromTarget": target_contains, "nextAction": action, + }) + if before != remote_heads(root): + observation["status"] = "changed" + return unknown() + return observation + except (ObservationError, ValueError): + observation["status"] = "unavailable" + return unknown() + + +CONTROLLER_STORE_SCHEMA = "subactor.repository-change-lease-store/v1" +FREE_LEASE_PHASES = {"closed", "cancelled", "released"} +REPOSITORY_REF = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") +RESOURCE_ACTIONS = { + "AVAILABLE": "Acquire the editing lease from the controller, then re-run work-start with --ticket before any write.", + "HELD_BY_TICKET": "Continue only under the existing lease after verifying its owner, fencing token and expiry.", + "HELD_BY_OTHER": "Wait for the holder's release or a protected handoff; do not prepare an expiring admission now.", + "HELD_EXPIRED": "The expired holder still blocks the resource until the controller releases it or records a " + "protected takeover; expiry alone never authorizes takeover.", + "UNAVAILABLE": "Restore controller access; do not infer availability from disjoint paths or retry an admission.", +} + + +def _controller_bytes(store): + path = Path(os.path.abspath(store)) / "state.json" + if any(p.is_symlink() for p in (path, *path.parents)): + raise ObservationError("Symlinked controller state") + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ObservationError("Controller state must be a regular file") + raw = stream.read(4 * 1024 * 1024 + 1) + if len(raw) > 4 * 1024 * 1024: + raise ObservationError("Controller state too large") + return raw + + +def controller_observation(store, repository, target, ticket=None): + """Read-only availability of the controller's exclusive editing resource; never a grant or a queue entry.""" + observation = { + "schema": "new-project.editing-resource-observation/v1", + "observedAt": datetime.now(timezone.utc).isoformat(), + "readOnly": True, "grantsAuthority": False, "queueSupported": False, + "repositoryRef": repository, "targetBranch": target, "ticket": ticket, + "resource": digest({"repositoryRef": repository, "targetBranch": target}), + "state": "UNAVAILABLE", "reason": None, "holder": None, "stateDigest": None, + } + try: + raw = _controller_bytes(store) + except FileNotFoundError: + observation["reason"] = "controller-store-missing" + except (OSError, ObservationError): + observation["reason"] = "controller-state-unreadable" + else: + try: + state = json.loads(raw) + if not isinstance(state, dict) or state.get("schema") != CONTROLLER_STORE_SCHEMA: + raise ValueError("unsupported store") + holder_id = state["active"].get(observation["resource"]) + holder = None if holder_id is None else state["leases"][holder_id] + if holder is not None: + expires = datetime.fromisoformat(holder["expiresAt"].replace("Z", "+00:00")) + if expires.tzinfo is None or not isinstance(holder["phase"], str): + raise ValueError("invalid holder") + except (ValueError, KeyError, TypeError, AttributeError): + observation["reason"] = "controller-state-invalid" + else: + if _controller_bytes(store) != raw: + observation["reason"] = "controller-state-changed" + elif holder is None or holder["phase"] in FREE_LEASE_PHASES: + observation["state"] = "AVAILABLE" + else: + # A safe reference only: no owner session, credential or plan. + observation["holder"] = {"leaseId": holder_id, "ticketId": holder.get("ticketId"), + "phase": holder["phase"], "expiresAt": holder["expiresAt"]} + if holder["phase"] == "expired" or expires <= datetime.now(timezone.utc): + observation["state"] = "HELD_EXPIRED" + elif ticket is not None and holder.get("ticketId") == ticket: + observation["state"] = "HELD_BY_TICKET" + else: + observation["state"] = "HELD_BY_OTHER" + observation["stateDigest"] = hashlib.sha256(raw).hexdigest() + observation["nextAction"] = RESOURCE_ACTIONS[observation["state"]] + return observation + + +def inspect(root, workstream, requested_paths=(), ticket=None, storage=None, + observe_publication=False, expected_dirty_digest=None, recovery_intent=None, + bootstrap_adoption_digest=None, controller=None): + root = Path(git(root, "rev-parse", "--show-toplevel").strip()).resolve() + manifest = manifest_at(root) + coordination = manifest["coordination"] + declared = coordination["workstreams"] + if workstream not in declared: + raise UnknownWorkstream(sorted(name for name in declared + if isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name))) + stream = declared[workstream] + limit = coordination["maxActiveTicketsPerWorkstream"] + if type(limit) is not int or limit < 1: + raise ObservationError("Invalid workstream WIP limit") + targets = manifest["delivery"]["targetBranches"] + if not isinstance(targets, list) or len(targets) != 1: + raise ObservationError("A unique declared target branch is required") + target = targets[0] + if not isinstance(target, str) or not re.fullmatch(r"[A-Za-z0-9._/-]+", target): + raise ObservationError("Unsafe target branch") + refs = git(root, "for-each-ref", "--format=%(refname) %(objectname)", + "refs/heads", "refs/remotes") + refs_map = dict(line.split(" ", 1) for line in refs.splitlines()) + target_refs = {ref: refs_map[ref] for ref in + ("refs/heads/" + target, "refs/remotes/origin/" + target) + if ref in refs_map} + if not target_refs: + raise ObservationError("Target branch observation missing; do not guess main") + # Both observations are retained. Prefer the fetched remote, never fetch. + target_sha = target_refs.get("refs/remotes/origin/" + target, + target_refs.get("refs/heads/" + target)) + registrations = worktrees(root) + primary = Path(registrations[0]["worktree"]).resolve() + statuses = set(manifest["ticket"]["activeStatuses"]) + mode = storage or configured_mode(root) + if mode not in {"files", "sqlite"}: + raise ObservationError("Unsupported ticket storage") + if bootstrap_adoption_digest and mode != "files": + raise ObservationError("Bootstrap acknowledgement requires file allocation") + bootstrap_owned = (bootstrap_owned_paths(primary, manifest, bootstrap_adoption_digest) + if bootstrap_adoption_digest else set()) + database = primary_database(root) if mode == "sqlite" else None + records = {item["ticket"]: item for item in load_input(root, database=database)} if database and database.exists() else {} + entries = [] + for registration in registrations: + path = Path(registration["worktree"]) + if not path.is_dir() or path.is_symlink(): + raise ObservationError("Registered checkout unavailable; preserve and reconcile") + head = git(path, "rev-parse", "--verify", "HEAD").strip() + branch = (git(path, "symbolic-ref", "--quiet", "HEAD", optional=True) or "").strip() + if head != registration.get("HEAD") or branch != registration.get("branch", ""): + raise ObservationError("Checkout changed during observation") + dirty, dirty_hash, all_dirty = dirty_observation(path) + if bootstrap_adoption_digest and path == primary and dirty_hash != bootstrap_adoption_digest: + raise ObservationError("Bootstrap changed during admission") + ahead, behind = map(int, git(root, "rev-list", "--left-right", "--count", + head + "..." + target_sha).split()) + match = TICKET.fullmatch(branch.removeprefix("refs/heads/")) + ticket_id = "ticket-" + match[1] if match else None + intent, active, status, activity_authority = None, False, None, "unresolved" + recovering = (recovery_intent is not None and ticket_id == ticket + and path.resolve() == root and mode == "files") + pending = bool(all_dirty or ahead or ticket_id in records or recovering) + if ticket_id and pending: + ticket_dir = path / "project" / ticket_id + if ticket_dir.is_symlink(): + raise ObservationError("Symlinked ticket directory") + record = records.get(ticket_id) + if mode == "sqlite": + if not record: + raise ObservationError("Branch ticket absent from selected SQLite input") + intent = json.loads(record["files"]["intent.json"][0]) + readme = record["files"]["README.md"][0].decode("utf-8") + status_match = re.search(r"(?mi)^-[ \t]+\*\*Status\*\*:[ \t]*([A-Z_]+)[ \t]*$", readme) + if status_match is None: + raise ObservationError("SQLite ticket status unknown") + status_override = status_match[1] + elif recovering: + if ticket_dir.exists(): + raise ObservationError("Recovery cannot replace an existing ticket") + intent = recovery_intent + status_override = "IN_PROGRESS" + else: + try: + intent = read_json(ticket_dir / "intent.json") + except ObservationError: + # A quarantined checkout may hold an interrupted merge. Record it + # instead of aborting every allocation; its Git delta is still + # compared below as pending work. + if not quarantined(path, primary): + raise + intent = None + status_override = None + if intent is None: + active, status, activity_authority = False, "QUARANTINED_UNREADABLE", "quarantine" + else: + if intent.get("ticket") != ticket_id or intent.get("schema") not in ( + "new-project.intent/v2", "new-project.intent/v3"): + raise ObservationError("Branch/intent identity mismatch") + patterns(intent.get("allowedPaths")) + resolution = resolve_activity(path, ticket_dir, statuses, status_override=status_override) + active, status = resolution.active, resolution.projectionStatus + activity_authority = resolution.authority + if status is None: + raise ObservationError("Ticket status unknown") + canonical = bool(ticket_id and path.resolve().parent == primary / ".worktrees" + and path.name.startswith(ticket_id + "--")) + entries.append({"path": str(path.resolve()), "branch": branch or None, + "headSha": head, "ahead": ahead, "behind": behind, + "dirtyPaths": dirty, "allDirtyPaths": all_dirty, "dirtyDigest": dirty_hash, + "dirtyNewestModifiedAt": dirty_modified(path, all_dirty), + "pending": pending, "ticket": ticket_id, + "workstream": intent.get("workstream") if intent else None, + "allowedPaths": material(intent["allowedPaths"]) if intent else [], + "intentDigest": digest(intent) if intent else None, + "active": active, "status": status, + "activityAuthority": activity_authority, "canonical": canonical, + "writerAuthority": "unverified"}) + matches = [e for e in entries if ticket and e["ticket"] == ticket] + if ticket and len(matches) != 1: + raise ObservationError("Requested ticket has no unique registered checkout") + selected = matches[0] if matches else None + requested = material(patterns(list(requested_paths) if requested_paths else + selected["allowedPaths"] if selected else stream["ownedPaths"])) + if not requested: + raise ObservationError("Material work scope required; use read-only inspection for carriers") + if selected and (selected["workstream"] != workstream or not selected["canonical"]): + raise ObservationError("Existing ticket requires ownership/layout reconciliation") + if selected and any(not path_ignored(p, tuple(selected["allowedPaths"])) for p in requested): + raise ObservationError("Requested scope exceeds the existing intent") + comparison_sha = selected["headSha"] if selected else target_sha + blockers = [] + active_tickets = set() + assigned = {entry["ticket"] for entry in entries if entry["ticket"]} + # Newly materialized ticket intent without its own branch is also pending + # work. Do not resurrect inherited historical carrier copies in every tree. + unassigned = {} + for entry in entries: + path = Path(entry["path"]) + if mode == "sqlite": + candidates = {key: row for key, row in records.items() if key not in assigned} + else: + ids = {p.split("/")[1] for p in entry["allDirtyPaths"] + if re.match(r"^project/ticket-[0-9]{3,}/", p)} + candidates = {key: None for key in ids if key not in assigned} + for key, record in candidates.items(): + if key in unassigned: + continue + if mode == "sqlite": + raw = record["files"]["README.md"][0].decode("utf-8") + match = re.search(r"(?mi)^-[ \t]+\*\*Status\*\*:[ \t]*([A-Z_]+)[ \t]*$", raw) + if match is None: + raise ObservationError("Unassigned ticket status unavailable") + intent = json.loads(record["files"]["intent.json"][0]) + resolution = resolve_activity(path, path / "project" / key, statuses, status_override=match[1]) + else: + ticket_dir = path / "project" / key + resolution = resolve_activity(path, ticket_dir, statuses) + if resolution.projectionStatus is None: + raise ObservationError("Unassigned ticket status unavailable") + if not resolution.active and _historical_inactive_logs( + path, ticket_dir, key, resolution.projectionStatus, + entry["allDirtyPaths"], target_sha): + continue + intent = read_json(ticket_dir / "intent.json") + scope = material(patterns(intent.get("allowedPaths"))) + unassigned[key] = True + if resolution.active and intent.get("workstream") == workstream: + active_tickets.add(key) + relevant = intersects(requested, scope) or intent.get("workstream") == workstream + if resolution.active and mode == "files" and relevant and landed(path, key, target_sha): + # A dirty carrier copy of a ticket already on the observed target + # still projects activity (the conservative default is kept). + # Name it instead of silently holding the workstream limit. + blockers.append({"path": str(path), "branch": entry["branch"], "ticket": key, + "active": resolution.active, "reason": "integrated-ticket-carrier"}) + elif resolution.active and (intersects(requested, scope) or (not scope and intent.get("workstream") == workstream)): + blockers.append({"path": str(path), "branch": entry["branch"], "ticket": key, + "active": resolution.active, "reason": "unassigned-ticket"}) + for entry in entries: + if entry["active"] and entry["workstream"] == workstream: + active_tickets.add(entry["ticket"]) + if entry is selected or not entry["pending"]: + continue + contribution = changes(root, comparison_sha, entry["headSha"]) + pending_paths = [p for p in entry["dirtyPaths"] + if not (Path(entry["path"]) == primary and p in bootstrap_owned)] + if (pending_paths and Path(entry["path"]) != primary and entry["branch"] is None + and entry["ticket"] is None and not entry["active"] and entry["ahead"] == 0): + pending_paths = outstanding_detached_paths(Path(entry["path"]), pending_paths, target_sha) + contested = intersects(requested, pending_paths + contribution) + reserved = entry["active"] and intersects(requested, entry["allowedPaths"]) + if contested or reserved: + blockers.append({"path": entry["path"], "branch": entry["branch"], + "ticket": entry["ticket"], "active": entry["active"], + "reason": "scope-reservation" if reserved else "pending-delta"}) + checked = {e["branch"] for e in entries} + branches = [] + target_trees = {} + for ref, sha in sorted(refs_map.items()): + if not ref.startswith("refs/heads/") or ref in checked or ref == "refs/heads/" + target: + continue + ahead, behind = map(int, git(root, "rev-list", "--left-right", "--count", + sha + "..." + target_sha).split()) + branches.append({"branch": ref, "headSha": sha, "ahead": ahead, "behind": behind}) + if ahead and intersects(requested, changes(root, comparison_sha, sha)): + # Only branches WITHOUT a registered checkout reach this path. + # Require every unique snapshot AFTER divergence (not just HEAD). + # An intentional new rollback must not match a pre-branch snapshot. + # Preserve refs; this is neither terminal nor cleanup authority. + ancestor = git(root, "merge-base", target_sha, sha).strip() + if ancestor != target_sha and ancestor not in target_trees: + target_trees[ancestor] = commit_trees(root, ancestor + ".." + target_sha, ancestry_path=True) + if (ancestor != target_sha and + commit_trees(root, target_sha + ".." + sha) <= target_trees[ancestor]): + continue + blockers.append({"path": None, "branch": ref, "ticket": None, + "active": False, "reason": "unassigned-branch-delta"}) + required = ["current intent and session authority", "verified owner or accepted handoff", + "controller lease CAS and fencing", "fresh preflight and governance gate"] + if selected: + # The selected checkout is excluded from peer contention, yet another + # writer may have left uncommitted changes in it. Recency is evidence + # only; the opt-in digest CAS detects any change since the caller's + # previous observation. + overlap = [p for p in selected["dirtyPaths"] if path_ignored(p, tuple(requested))] + if expected_dirty_digest is not None and expected_dirty_digest != selected["dirtyDigest"]: + blockers.append({"path": selected["path"], "branch": selected["branch"], + "ticket": selected["ticket"], "active": selected["active"], + "reason": "selected-checkout-changed"}) + elif expected_dirty_digest is None and overlap: + required.append(f"confirm that {len(overlap)} uncommitted requested path(s) in the selected checkout " + f"(newest {selected['dirtyNewestModifiedAt']}) belong to this session, then pass " + f"--expect-dirty-digest {selected['dirtyDigest']}") + route = "NEW_TICKET_CANDIDATE" + if blockers: + route = ("RECONCILE" if any(b["ticket"] is None or b["reason"] in {"unassigned-ticket", "integrated-ticket-carrier"} + for b in blockers) else + "ASSIST_READ_ONLY" if any(b["active"] for b in blockers) else "HANDOFF_REQUIRED") + elif selected: + route = "REUSE_EXISTING" + elif len(active_tickets) >= limit: + route = "SERIALIZE" + payload = {"schema": SCHEMA, "readOnly": True, "grantsAuthority": False, + "createsWorktree": False, "scope": "registered-clone-local", + "primaryCheckout": str(primary), "targetBranch": target, + "targetObservations": target_refs, "remoteFreshness": "not-refreshed", + "workstream": workstream, "requestedPaths": requested, + "requestedTicket": ticket, "route": route, "diagnostic": None, + "worktrees": entries, "uncheckedBranches": branches, "blockers": blockers, + "activeTicketCount": len(active_tickets), "workstreamLimit": limit, + "requiredBeforeWrite": required, + "observationDigest": ""} + if bootstrap_adoption_digest: + payload["bootstrapOwnerAcknowledgement"] = {"dirtyDigest": bootstrap_adoption_digest, + "paths": sorted(bootstrap_owned), + "grantsWriterAuthority": False} + storage_digest = digest({key: {"revision": row["revision"], + "files": {name: hashlib.sha256(value[0]).hexdigest() + for name, value in row["files"].items()}} + for key, row in records.items()}) + if observe_publication: + payload["publication"] = publication_observation(root, entries, target) + if controller: + payload["editingResource"] = controller_observation(*controller, target, ticket) + payload["observationDigest"] = digest({"refs": refs, "manifest": manifest, "report": payload, + "ticketStorage": mode, "ticketInputDigest": storage_digest}) + if refs != git(root, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/remotes"): + raise ObservationError("Refs changed during observation; retry") + if manifest != manifest_at(root) or registrations != worktrees(root): + raise ObservationError("Manifest or worktree registrations changed; retry") + for entry in entries: + if dirty_observation(Path(entry["path"]))[1] != entry["dirtyDigest"]: + raise ObservationError("Workspace changed during observation; retry") + if database and database.exists(): + if records != {item["ticket"]: item for item in load_input(root, database=database)}: + raise ObservationError("Ticket database changed during observation; retry") + return payload + + +def refresh_autonomy_board(root): + """Best effort: every work start rewrites the ignored GOV-IDLE-TAKEOVER board; it never changes the route.""" + checker = Path(__file__).resolve().parent / "idle_takeover.py" + if not checker.is_file(): + return + try: + spec = importlib.util.spec_from_file_location("work_start_idle_takeover", checker) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + board = module.observe(Path(root)) + path = module.write_board(board) + except Exception: # noqa: BLE001 - the board is advisory; observation errors must not alter the route + return + eligible = [item["ticket"] for item in board["tickets"] if item["state"] == "IDLE_TAKEOVER_ELIGIBLE"] + print(f"autonomy board: {path}" + (f"; idle takeover candidates: {', '.join(eligible)}" if eligible else ""), + file=sys.stderr) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--workstream", required=True) + parser.add_argument("--ticket") + parser.add_argument("--path", action="append", default=[]) + parser.add_argument("--allocation-check", action="store_true") + parser.add_argument("--bootstrap-adoption-digest", metavar="SHA256", + help="Explicitly acknowledge owned first-scaffold installer bytes at this exact dirty digest") + parser.add_argument("--storage", choices=["files", "sqlite"]) + parser.add_argument("--observe-publication", action="store_true", + help="Read origin refs twice without fetching; distinguish remote code from integration/release authority.") + parser.add_argument("--controller-store", type=Path, metavar="DIR", + help="Read-only availability of the controller editing resource (state.json); never a grant.") + parser.add_argument("--controller-repository", metavar="OWNER/NAME", + help="Exact repository reference the controller resource is bound to (with --controller-store).") + parser.add_argument("--expect-dirty-digest", metavar="SHA256", + help="With --ticket: dirtyDigest of the selected checkout from this session's previous observation; " + "a mismatch blocks reuse (clone-local CAS, not a lease).") + args = parser.parse_args(argv) + if args.expect_dirty_digest is not None and ( + not args.ticket or not re.fullmatch(r"[0-9a-f]{64}", args.expect_dirty_digest)): + parser.error("--expect-dirty-digest requires --ticket and a lowercase SHA-256 digest") + if (args.controller_store is None) != (args.controller_repository is None) or ( + args.controller_repository is not None and not REPOSITORY_REF.fullmatch(args.controller_repository)): + parser.error("--controller-store and --controller-repository OWNER/NAME must be passed together") + if args.bootstrap_adoption_digest is not None and ( + not args.allocation_check or args.ticket or args.storage == "sqlite" + or not re.fullmatch(r"[0-9a-f]{64}", args.bootstrap_adoption_digest)): + parser.error("--bootstrap-adoption-digest requires first file allocation and SHA-256") + try: + payload = inspect(args.root, args.workstream, args.path, args.ticket, args.storage, + args.observe_publication, args.expect_dirty_digest, + bootstrap_adoption_digest=args.bootstrap_adoption_digest, + controller=(args.controller_store, args.controller_repository) + if args.controller_store else None) + except (ObservationError, ActivityError, KeyError, TypeError, ValueError, OSError, StopIteration) as error: + # Stdout stays generic — no exception content: remote URLs or + # secret-bearing input never leak there. The real cause is written + # to a local-only log instead of being discarded (see + # _record_observation_failure). + if isinstance(error, UnknownWorkstream): + # The whole cause fits on stdout, so nothing is logged. Declared + # names come from the checked-in manifest and disclose nothing; + # the caller's raw argument is not echoed. + print(json.dumps({"schema": SCHEMA, "readOnly": True, "grantsAuthority": False, + "createsWorktree": False, "route": "RECONCILE", "diagnostic": CODE, + "reason": "GOV-WORKSTREAM-001: --workstream is not declared in the governance " + "manifest; no checkout needs reconciling. Declared workstreams: " + + (", ".join(error.declared) or "none") + "."})) + return 3 + logged = _record_observation_failure(args.root, error) + + print(json.dumps({"schema": SCHEMA, "readOnly": True, "grantsAuthority": False, + "createsWorktree": False, "route": "RECONCILE", "diagnostic": CODE, + "reason": "Observation incomplete or inconsistent; preserve work and reconcile. " + + ("Real cause logged locally in " + _OBSERVATION_LOG + + " (ignored by Git) — read it before opening a new ticket." + if logged else "Local diagnostic was not written; preserve work " + "and inspect the existing checkout and ignore policy.")})) + return 3 + if args.allocation_check and payload["route"] != "NEW_TICKET_CANDIDATE": + payload["diagnostic"] = CODE + refresh_autonomy_board(args.root) + print(json.dumps(payload, sort_keys=True, ensure_ascii=True)) + return 3 if payload["diagnostic"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/workspace_lifecycle_check.py b/.governance/workspace_lifecycle_check.py new file mode 100755 index 0000000..333d08c --- /dev/null +++ b/.governance/workspace_lifecycle_check.py @@ -0,0 +1,735 @@ +#!/usr/bin/env python3 +"""Audit a workspace root for temporary checkouts and orphan local branches.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import re +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +REPORT_SCHEMA = "new-project.workspace-lifecycle-report/v1" +MAX_REPOSITORIES = 10_000 +SCP_REMOTE_RE = re.compile(r"^(?:[^@/]+@)?([^:/]+):(.+)$") +TICKET_DIRECTORY_RE = re.compile(r"^ticket-([0-9]+)$") + + +@dataclass(order=True) +class Finding: + code: str + severity: str + message: str + remediation: str + evidence: dict[str, Any] + + +@dataclass(frozen=True) +class TicketClaim: + number: int + ticket: str | None + summary: str | None + workstream: str | None + path: str + + +@dataclass(frozen=True) +class LocalBranch: + name: str + head: str + + +@dataclass(frozen=True) +class Checkout: + path: Path + common_git_dir: Path + identity: str + head: str | None + branch: str | None + dirty: bool + tickets: tuple[TicketClaim, ...] + + +class AuditError(RuntimeError): + """The local workspace could not be audited safely.""" + + +def load_worktrees_contract(): + """Load the pinned owner inventory without creating bytecode in the checkout.""" + script = Path(__file__).resolve() + candidates = ( + script.with_name("worktree_path_check.py"), + script.parent.parent / "subprojects" / "worktrees" / "conformance.py", + ) + source = next((candidate for candidate in candidates if candidate.is_file()), None) + if source is None: + raise AuditError("the managed Worktrees conformance module is missing") + spec = importlib.util.spec_from_file_location("workspace_worktrees_contract", source) + if spec is None or spec.loader is None: + raise AuditError(f"cannot load Worktrees conformance from {source}") + module = importlib.util.module_from_spec(spec) + previous = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + sys.modules[spec.name] = module + spec.loader.exec_module(module) + except (ImportError, OSError, ValueError) as error: + raise AuditError(f"cannot load Worktrees conformance: {error}") from error + finally: + sys.dont_write_bytecode = previous + sys.modules.pop(spec.name, None) + return module + + +def run_git(root: Path, *arguments: str) -> str: + try: + result = subprocess.run( + ["git", "-C", str(root), *arguments], + capture_output=True, + check=False, + text=True, + timeout=15, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise AuditError(f"git failed for {root}: {error}") from error + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise AuditError(f"git {' '.join(arguments)} failed for {root}: {detail}") + return result.stdout.strip() + + +def local_remote_path(root: Path, remote: str) -> Path | None: + if remote.startswith("file://"): + parsed = urlparse(remote) + return Path(parsed.path).resolve() + candidate = Path(remote).expanduser() + if candidate.is_absolute() or remote.startswith(("./", "../")): + if not candidate.is_absolute(): + candidate = root / candidate + return candidate.resolve() + return None + + +def normalized_network_remote(remote: str) -> str: + value = remote.strip().rstrip("/") + try: + parsed = urlparse(value) + port = parsed.port + except ValueError as error: + raise AuditError("Network remote has an invalid host or port") from error + if parsed.scheme and parsed.hostname: + path = parsed.path.lstrip("/") + host = parsed.hostname.lower() + default_port = {"ssh": 22, "https": 443, "http": 80, "git": 9418}.get(parsed.scheme) + if port is not None and port != default_port: + host = f"[{host}]:{port}" if ":" in host else f"{host}:{port}" + else: + match = SCP_REMOTE_RE.fullmatch(value) + if not match: + return f"remote:{value.removesuffix('.git').lower()}" + host, path = match.groups() + host = host.lower() + return f"remote:{host}/{path.removesuffix('.git').lower()}" + + +def repository_identity(root: Path, seen: set[Path] | None = None) -> str: + resolved = root.resolve() + visited = set() if seen is None else set(seen) + if resolved in visited: + raise AuditError(f"local origin cycle detected at {resolved}") + visited.add(resolved) + try: + remote = run_git(resolved, "remote", "get-url", "origin") + except AuditError as error: + if "No such remote" not in str(error): + raise + return f"local-repository:{resolved}" + local = local_remote_path(resolved, remote) + if local is not None and (local / ".git").exists(): + return repository_identity(local, visited) + if local is not None: + return f"local:{local}" + return normalized_network_remote(remote) + + +def checkout_head(path: Path) -> str | None: + try: + return run_git(path, "rev-parse", "--verify", "HEAD") + except AuditError: + status = run_git( + path, + "status", + "--porcelain=v2", + "--branch", + "--untracked-files=no", + ) + if "# branch.oid (initial)" in status.splitlines(): + return None + raise + + +def inspect_checkout(path: Path) -> Checkout: + common = Path( + run_git(path, "rev-parse", "--path-format=absolute", "--git-common-dir") + ).resolve() + head = checkout_head(path) + branch = run_git(path, "branch", "--show-current") or None + dirty = bool(run_git(path, "status", "--porcelain=v1", "--untracked-files=all")) + identity = repository_identity(path) + tickets = ticket_claims(path) + return Checkout( + path=path.resolve(), + common_git_dir=common, + identity=identity, + head=head, + branch=branch, + dirty=dirty, + tickets=tickets, + ) + + +def ticket_claims(root: Path) -> tuple[TicketClaim, ...]: + project = root / "project" + if not project.is_dir(): + return () + claims: list[TicketClaim] = [] + for directory in sorted(project.iterdir(), key=lambda item: item.name): + match = TICKET_DIRECTORY_RE.fullmatch(directory.name) + if not directory.is_dir() or match is None: + continue + intent_path = directory / "intent.json" + intent: dict[str, Any] = {} + try: + value = json.loads(intent_path.read_text(encoding="utf-8")) + if isinstance(value, dict): + intent = value + except (OSError, json.JSONDecodeError): + pass + claims.append(TicketClaim( + number=int(match.group(1)), + ticket=intent.get("ticket") if isinstance(intent.get("ticket"), str) else None, + summary=intent.get("summary") if isinstance(intent.get("summary"), str) else None, + workstream=( + intent.get("workstream") + if isinstance(intent.get("workstream"), str) + else None + ), + path=str(directory.resolve()), + )) + return tuple(claims) + + +def highest_ref_ticket(root: Path) -> int: + highest = 0 + refs = run_git( + root, + "for-each-ref", + "--format=%(refname)", + "refs/heads", + "refs/remotes", + ).splitlines() + for ref in refs: + paths = run_git(root, "ls-tree", "-d", "-r", "--name-only", ref, "--", "project") + for raw_path in paths.splitlines(): + match = re.fullmatch(r"project/ticket-([0-9]+)", raw_path) + if match: + highest = max(highest, int(match.group(1))) + return highest + + +def allocation_high_water(common_git_dir: Path) -> tuple[int | None, str | None]: + state = common_git_dir / "new-project-ticket-high-water" + if not state.exists(): + return None, None + try: + raw = state.read_text(encoding="utf-8").strip() + except OSError as error: + return None, str(error) + if not raw.isdigit(): + return None, "high-water state is not a decimal ticket number" + return int(raw), None + + +def allocation_findings(checkouts: list[Checkout]) -> list[Finding]: + findings: list[Finding] = [] + groups: dict[Path, list[Checkout]] = {} + for checkout in checkouts: + groups.setdefault(checkout.common_git_dir, []).append(checkout) + + for common_git_dir, group in sorted(groups.items(), key=lambda item: str(item[0])): + primary = min(group, key=lambda item: str(item.path)) + ref_highest = highest_ref_ticket(primary.path) + high_water, state_error = allocation_high_water(common_git_dir) + reserved_highest = max(ref_highest, high_water or 0) + if state_error: + findings.append(Finding( + code="GOV-TICKET-ALLOCATION-001", + severity="error", + message="The clone-wide ticket allocation reservation is unreadable.", + remediation=( + "Stop allocators, preserve every ticket worktree and repair the shared " + "high-water state through the managed allocator before assigning a number." + ), + evidence={"reason": state_error}, + )) + + claims_by_number: dict[int, list[TicketClaim]] = {} + for checkout in group: + for claim in checkout.tickets: + claims_by_number.setdefault(claim.number, []).append(claim) + if claim.number > reserved_highest: + findings.append(Finding( + code="GOV-TICKET-ALLOCATION-001", + severity="error", + message="A ticket directory is outside the clone-wide reservation.", + remediation=( + "Do not reuse or rename it automatically. Preserve the worktree, " + "classify ownership, then allocate through project/new-ticket.sh." + ), + evidence={ + "path": claim.path, + "refHighest": ref_highest, + "reservedHighWater": high_water, + "ticket": f"ticket-{claim.number:03d}", + }, + )) + + for number, claims in sorted(claims_by_number.items()): + identities = { + (claim.ticket, claim.summary, claim.workstream) + for claim in claims + } + if len(identities) <= 1: + continue + findings.append(Finding( + code="GOV-TICKET-ALLOCATION-002", + severity="error", + message="Linked worktrees assign different intents to the same ticket ID.", + remediation=( + "Stop both writers and preserve both heads. Keep the earlier reserved " + "identity, allocate a new ID through project/new-ticket.sh for the other " + "workstream, then rebuild its branch without mixing histories." + ), + evidence={ + "claims": [asdict(claim) for claim in sorted(claims, key=lambda item: item.path)], + "ticket": f"ticket-{number:03d}", + }, + )) + return findings + + +def registered_worktrees(path: Path) -> list[Path]: + worktrees: list[Path] = [] + for line in run_git(path, "worktree", "list", "--porcelain").splitlines(): + if line.startswith("worktree "): + worktrees.append(Path(line.removeprefix("worktree ")).resolve()) + return worktrees + + +def local_branches(path: Path) -> tuple[LocalBranch, ...]: + branches: list[LocalBranch] = [] + output = run_git( + path, + "for-each-ref", + "--format=%(refname:short)\t%(objectname)", + "refs/heads", + ) + for line in output.splitlines(): + name, separator, head = line.partition("\t") + if not separator or not name or not head: + raise AuditError(f"local branch inventory is malformed for {path}") + branches.append(LocalBranch(name=name, head=head)) + return tuple(sorted(branches, key=lambda item: item.name)) + + +def default_branch(path: Path, branches: tuple[LocalBranch, ...]) -> str | None: + if not branches: + return None + try: + remote_head = run_git( + path, + "symbolic-ref", + "--quiet", + "--short", + "refs/remotes/origin/HEAD", + ) + except AuditError: + remote_head = "" + if remote_head.startswith("origin/"): + return remote_head.removeprefix("origin/") + + names = {branch.name for branch in branches} + for conventional in ("main", "master"): + if conventional in names: + return conventional + if len(branches) == 1: + return branches[0].name + raise AuditError( + f"default branch cannot be resolved without origin/HEAD for {path}" + ) + + +def choose_primary(checkouts: list[Checkout]) -> Checkout: + slug = checkouts[0].identity.rsplit("/", 1)[-1] + named = [checkout for checkout in checkouts if checkout.path.name.lower() == slug] + if len(named) == 1: + return named[0] + common_owners = [ + checkout + for checkout in checkouts + if checkout.common_git_dir == checkout.path / ".git" + ] + return min( + common_owners or checkouts, + key=lambda item: (len(item.path.parts), str(item.path)), + ) + + +def workspace_inventory(checkouts: list[Checkout]) -> dict[str, Any]: + """Compose owner layout classes with adopter-owned duplicate-clone evidence.""" + contract = load_worktrees_contract() + path_style = "windows" if os.name == "nt" else "posix" + clone_groups: dict[Path, list[Checkout]] = {} + identity_groups: dict[str, list[Checkout]] = {} + for checkout in checkouts: + clone_groups.setdefault(checkout.common_git_dir, []).append(checkout) + identity_groups.setdefault(checkout.identity, []).append(checkout) + + layout_entries: dict[Path, dict[str, Any]] = {} + for _, group in sorted(clone_groups.items(), key=lambda item: str(item[0])): + primary = choose_primary(group) + try: + observed = contract.inventory( + repository=primary.identity, + repository_name=primary.path.name, + primary_checkout=str(primary.path), + registered=[ + { + "path": str(checkout.path), + "head": checkout.head, + "branch": checkout.branch, + "detached": checkout.branch is None, + } + for checkout in group + ], + path_style=path_style, + ) + except (TypeError, ValueError) as error: + raise AuditError(f"Worktrees inventory failed: {error}") from error + if observed.get("readOnly") is not True: + raise AuditError("Worktrees inventory did not declare readOnly=true") + for entry in observed["entries"]: + layout_entries[Path(entry["path"])] = entry + + authoritative_clone: dict[str, Path] = { + identity: choose_primary(group).common_git_dir + for identity, group in identity_groups.items() + } + entries: list[dict[str, Any]] = [] + for checkout in sorted(checkouts, key=lambda item: str(item.path)): + layout = layout_entries.get(checkout.path) + if layout is None: + raise AuditError(f"Worktrees inventory omitted {checkout.path}") + duplicate = checkout.common_git_dir != authoritative_clone[checkout.identity] + anomalies = list(layout["anomalies"]) + if duplicate: + anomalies.append("duplicate-clone") + entries.append({ + "path": str(checkout.path), + "identity": checkout.identity, + "branch": checkout.branch, + "head": checkout.head, + "dirty": checkout.dirty, + "classification": layout["classification"], + "layoutVersion": layout["layoutVersion"], + "ticket": layout["ticket"], + "slug": layout["slug"], + "cloneClassification": "duplicate-clone" if duplicate else "registered", + "anomalies": sorted(set(anomalies)), + }) + return {"schema": contract.SCHEMA, "readOnly": True, "entries": entries} + + +def local_branch_findings( + checkouts: list[Checkout], allowed: set[Path] +) -> list[Finding]: + findings: list[Finding] = [] + clone_groups: dict[Path, list[Checkout]] = {} + for checkout in checkouts: + clone_groups.setdefault(checkout.common_git_dir, []).append(checkout) + + for _, group in sorted(clone_groups.items(), key=lambda item: str(item[0])): + primary = choose_primary(group) + branches = local_branches(primary.path) + default = default_branch(primary.path, branches) + checkout_by_branch = { + checkout.branch: checkout + for checkout in group + if checkout.branch is not None + } + for branch in branches: + if branch.name == default: + continue + active_checkout = checkout_by_branch.get(branch.name) + if active_checkout is not None and active_checkout.path in allowed: + continue + findings.append(Finding( + code="GOV-WORKSPACE-LIFECYCLE-004", + severity="error", + message="A terminal workspace still contains a non-default local branch.", + remediation=( + "Classify the branch HEAD and preserve unique history. After releasing " + "its worktree, delete only this exact disposable local ref; never let " + "the checker delete it automatically." + ), + evidence={ + "branch": branch.name, + "checkout": ( + str(active_checkout.path) + if active_checkout is not None + else None + ), + "defaultBranch": default, + "head": branch.head, + "identity": primary.identity, + "primary": str(primary.path), + }, + )) + return findings + + +def discover_workspace_repositories( + workspace_root: Path, allow_empty: bool = False +) -> set[Path]: + if not workspace_root.is_dir(): + raise AuditError(f"workspace root is not a directory: {workspace_root}") + candidates: list[Path] = [] + if (workspace_root / ".git").exists(): + candidates.append(workspace_root) + for child in workspace_root.iterdir(): + if not child.is_dir(): + continue + if (child / ".git").exists(): + candidates.append(child) + continue + for grandchild in child.iterdir(): + if grandchild.is_dir() and (grandchild / ".git").exists(): + candidates.append(grandchild) + candidate_paths = {candidate.resolve() for candidate in candidates} + if len(candidate_paths) > MAX_REPOSITORIES: + raise AuditError(f"workspace contains more than {MAX_REPOSITORIES} repositories") + + pending = sorted(candidate_paths, key=str) + inspected: set[Path] = set() + while pending: + candidate = pending.pop(0) + if candidate in inspected: + continue + inspected.add(candidate) + discovered = { + worktree + for worktree in registered_worktrees(candidate) + if worktree not in candidate_paths + } + candidate_paths.update(discovered) + if len(candidate_paths) > MAX_REPOSITORIES: + raise AuditError( + f"workspace contains more than {MAX_REPOSITORIES} repositories" + ) + pending.extend(sorted(discovered, key=str)) + if not candidate_paths and not allow_empty: + raise AuditError( + f"workspace root contains no Git repositories: {workspace_root}" + ) + return candidate_paths + + +def evaluate( + workspace_root: Path, + allowed: set[Path], + allow_empty: bool = False, + target_repository: Path | None = None, +) -> tuple[list[Finding], dict[str, Any]]: + candidate_paths = discover_workspace_repositories( + workspace_root, allow_empty=allow_empty + ) + checkouts = [ + inspect_checkout(candidate) for candidate in sorted(candidate_paths, key=str) + ] + inventory = workspace_inventory(checkouts) + if not inventory["entries"] and not allow_empty: + raise AuditError( + f"workspace inventory is empty for workspace root: {workspace_root}" + ) + if target_repository is not None: + target_resolved = target_repository.expanduser().resolve() + target_matched = False + try: + target_checkout = inspect_checkout(target_resolved) + target_matched = any( + c.path == target_resolved + or c.common_git_dir == target_checkout.common_git_dir + for c in checkouts + ) + except Exception: + target_matched = any(c.path == target_resolved for c in checkouts) + if not target_matched: + raise AuditError( + f"workspace inventory omitted required target repository: {target_repository}" + ) + inventory_by_path = { + Path(entry["path"]): entry for entry in inventory["entries"] + } + groups: dict[str, list[Checkout]] = {} + for checkout in checkouts: + groups.setdefault(checkout.identity, []).append(checkout) + + findings: list[Finding] = [] + findings.extend(allocation_findings(checkouts)) + findings.extend(local_branch_findings(checkouts, allowed)) + for identity in sorted(groups): + group = groups[identity] + if len(group) < 2: + continue + primary = choose_primary(group) + for checkout in sorted(group, key=lambda item: str(item.path)): + if checkout == primary or checkout.path in allowed: + continue + linked = checkout.common_git_dir == primary.common_git_dir + kind = "linked worktree" if linked else "duplicate clone" + findings.append(Finding( + code=( + "GOV-WORKSPACE-LIFECYCLE-001" + if linked + else "GOV-WORKSPACE-LIFECYCLE-002" + ), + severity="error", + message=f"A terminal workspace still contains a {kind}.", + remediation=( + "Verify dirty state and HEAD reachability. Preserve unknown or unique data; " + "then remove this exact workspace and its disposable local branch." + ), + evidence={ + "branch": checkout.branch, + "dirty": checkout.dirty, + "head": checkout.head, + "identity": identity, + "path": str(checkout.path), + "primary": str(primary.path), + "workspaceClassification": inventory_by_path[checkout.path], + }, + )) + return sorted( + findings, + key=lambda item: ( + item.code, + json.dumps(item.evidence, ensure_ascii=False, sort_keys=True), + ), + ), inventory + + +def report_payload( + findings: list[Finding], inventory: dict[str, Any] +) -> dict[str, Any]: + return { + "schema": REPORT_SCHEMA, + "status": "passed" if not findings else "failed", + "summary": {"errors": len(findings), "warnings": 0, "findings": len(findings)}, + "inventory": inventory, + "findings": [asdict(item) for item in findings], + } + + +def render_text(payload: dict[str, Any]) -> str: + lines: list[str] = [] + for finding in payload["findings"]: + evidence = json.dumps( + finding["evidence"], + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + lines.append(f"{finding['code']} ERROR: {finding['message']} [{evidence}]") + lines.append(f" remediation: {finding['remediation']}") + summary = payload["summary"] + label = "GOV-WORKSPACE-PASS" if payload["status"] == "passed" else "GOV-WORKSPACE-FAIL" + lines.append( + f"{label}: {payload['status']} " + f"({summary['errors']} errors, {summary['warnings']} warnings)" + ) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace-root", required=True, type=Path) + parser.add_argument( + "--allow", + action="append", + default=[], + type=Path, + help="Exact active secondary checkout allowed during this non-terminal audit.", + ) + parser.add_argument("--format", choices=("text", "json"), default="text") + parser.add_argument( + "--target-repository", + "--target-root", + "--target", + dest="target_repository", + default=None, + type=Path, + help="Exact target repository checkout that must be covered by the inventory.", + ) + parser.add_argument( + "--allow-empty", + action="store_true", + default=False, + help="Allow empty workspace repository inventory without failing the audit.", + ) + args = parser.parse_args(argv) + + findings: list[Finding] + inventory: dict[str, Any] + try: + allowed = {path.expanduser().resolve() for path in args.allow} + target_repo = ( + args.target_repository.expanduser().resolve() + if args.target_repository + else None + ) + findings, inventory = evaluate( + args.workspace_root.expanduser().resolve(), + allowed, + allow_empty=args.allow_empty, + target_repository=target_repo, + ) + except AuditError as error: + findings = [Finding( + code="GOV-WORKSPACE-LIFECYCLE-003", + severity="error", + message="The local workspace audit could not be completed safely.", + remediation="Repair repository metadata or narrow the explicit workspace root.", + evidence={"reason": str(error)}, + )] + inventory = { + "schema": None, + "readOnly": True, + "entries": [], + } + + payload = report_payload(findings, inventory) + if args.format == "json": + print(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))) + else: + print(render_text(payload)) + return 0 if payload["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/worktree_guard.py b/.governance/worktree_guard.py new file mode 100755 index 0000000..529d84d --- /dev/null +++ b/.governance/worktree_guard.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Run worktree-guard.yaml the way pyqual.yaml / goal.yaml force a pipeline.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + + +SCHEMA = "wellmanifest.worktree-guard/v1" +DEFAULT_INTERVAL = 60 + + +# git exports these into hooks. Inherited, they override `git -C ` and +# point every subprocess back at the repository being committed, which silently +# collapses the whole workspace into a single checkout and passes the gate. +GIT_SCOPE_ENV = ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_INDEX_FILE", + "GIT_INDEX_VERSION", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_PREFIX", + "GIT_NAMESPACE", + "GIT_CEILING_DIRECTORIES", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", +) + + +def detached_git_env() -> dict[str, str]: + return {k: v for k, v in os.environ.items() if k not in GIT_SCOPE_ENV} + +# Emitted by --print-pyqual-stage and consumed by install-worktree-guard.sh. +# Kept here so the runner and the installer cannot drift apart. +PYQUAL_TOOL = { + "name": "worktree_guard", + "binary": "python3", + "command": "python3 .governance/worktree_guard.py --root {workdir} --once", + "output": "", + "allow_failure": False, +} +PYQUAL_STAGE = { + "name": "worktree-overlap", + "tool": "worktree_guard", + "optional": False, +} + + +def load_yaml(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + try: + import yaml # type: ignore + except ImportError: + yaml = None + if yaml is not None: + data = yaml.safe_load(text) + if not isinstance(data, dict): + raise ValueError(f"{path} is not a mapping") + return data + if '"schema"' in text and text.lstrip().startswith("{"): + data = json.loads(text) + if not isinstance(data, dict): + raise ValueError(f"{path} is not a mapping") + return data + # Fail closed without inventing a second YAML parser. + raise ValueError( + "PyYAML is required to read worktree-guard.yaml; install PyYAML or run " + "worktree_overlap_check.py directly" + ) + + +def resolve_checker(root: Path, explicit: Path | None = None) -> Path: + if explicit is not None: + if not explicit.is_file(): + raise FileNotFoundError(f"checker not found: {explicit}") + return explicit + for candidate in ( + root / "scripts" / "worktree_overlap_check.py", + root / ".governance" / "worktree_overlap_check.py", + # A workspace root is usually not a repository at all, so the last + # resort is the checker shipped next to this runner. + Path(__file__).resolve().parent / "worktree_overlap_check.py", + ): + if candidate.is_file(): + return candidate + raise FileNotFoundError("worktree_overlap_check.py is not installed in this repository") + + +def resolve_config(root: Path, explicit: Path | None) -> Path: + if explicit is not None: + return explicit + for candidate in ( + root / "worktree-guard.yaml", + root / ".governance" / "worktree-guard.yaml", + ): + if candidate.is_file(): + return candidate + raise FileNotFoundError("worktree-guard.yaml is missing; copy it from wellmanifest/new-project") + + +def snapshot(root: Path, extra_roots: list[str]) -> str: + material: list[str] = [] + try: + result = subprocess.run( + ["git", "-C", str(root), "worktree", "list", "--porcelain"], + capture_output=True, + text=True, + check=False, + timeout=15, + env=detached_git_env(), + ) + material.append(result.stdout) + except (OSError, subprocess.TimeoutExpired) as error: + material.append(str(error)) + for raw in extra_roots: + candidate = (root / raw).resolve() if not Path(raw).is_absolute() else Path(raw) + if not candidate.is_dir(): + continue + try: + names = sorted(entry.name for entry in candidate.iterdir()) + except OSError: + names = [] + material.append(f"{candidate}:{','.join(names)}") + return hashlib.sha256("\n".join(material).encode()).hexdigest() + + +def run_once( + root: Path, + config: dict[str, Any], + output_format: str, + report: Path | None = None, + checker_path: Path | None = None, + scope: str = "auto", +) -> int: + pipeline = config.get("pipeline", {}) + checker = resolve_checker(root, checker_path) + command = [ + sys.executable, + str(checker), + "--workspace-root", + str(root), + "--format", + "json" if report is not None else output_format, + ] + for pattern in pipeline.get("ignore") or []: + command.extend(("--ignore", str(pattern))) + # A repository gate answers for its own repository. A workspace scan has no + # single identity to answer for, so it reports on everything it discovers. + if scope == "repository" or (scope == "auto" and (root / ".git").exists()): + command.extend(("--identity-of", str(root))) + command.extend(("--focus-checkout", str(root))) + if report is None: + return subprocess.run(command, check=False, env=detached_git_env()).returncode + + # A scheduled scan has nowhere to print to, so it leaves a machine-readable + # trail instead. The report is written whatever the verdict is; an empty or + # stale file is how a broken timer becomes visible. + result = subprocess.run( + command, check=False, capture_output=True, text=True, env=detached_git_env() + ) + report.parent.mkdir(parents=True, exist_ok=True) + payload = result.stdout.strip() or json.dumps( + { + "schema": "new-project.worktree-overlap-report/v1", + "status": "failed", + "summary": {"errors": 1, "warnings": 0, "findings": 0, "checkouts": 0}, + "findings": [ + { + "code": "GOV-WORKTREE-OVERLAP-003", + "severity": "error", + "message": "The overlap checker produced no report.", + "remediation": "Run the checker directly to see the failure.", + "evidence": {"stderr": result.stderr[-2000:]}, + } + ], + } + ) + report.write_text(payload + "\n", encoding="utf-8") + if output_format == "text": + print(f"worktree-guard: report written to {report}", flush=True) + return result.returncode + + +def watch( + root: Path, + config: dict[str, Any], + output_format: str, + interval: int, + report: Path | None = None, + checker_path: Path | None = None, + scope: str = "auto", +) -> int: + pipeline = config.get("pipeline", {}) + extra_roots = list(pipeline.get("detect", {}).get("workspace_roots") or []) + previous = "" + while True: + current = snapshot(root, extra_roots) + if current != previous: + previous = current + print("worktree-guard: change detected, running overlap check", flush=True) + # The topology fingerprint omits dirty/staged files. Check each interval + # so edits in existing worktrees cannot leave a stale passing report. + code = run_once(root, config, output_format, report, checker_path, scope) + if code != 0: + print("worktree-guard: overlap check failed", flush=True) + time.sleep(interval) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(".")) + parser.add_argument("--config", type=Path, default=None) + parser.add_argument("--once", action="store_true") + parser.add_argument("--watch", action="store_true") + parser.add_argument("--interval", type=int, default=0) + parser.add_argument("--format", choices=("text", "json"), default="text") + parser.add_argument( + "--report", + type=Path, + default=None, + help="Write the JSON report here instead of relying on stdout (timers).", + ) + parser.add_argument( + "--scope", + choices=("auto", "repository", "workspace"), + default="auto", + help=( + "auto: report on --root's own repository when it is a checkout, " + "otherwise on the whole workspace." + ), + ) + parser.add_argument( + "--checker", + type=Path, + default=None, + help="Explicit path to worktree_overlap_check.py.", + ) + parser.add_argument( + "--print-pyqual-stage", + action="store_true", + help="Print the pyqual.yaml custom_tool + stage that runs this guard.", + ) + args = parser.parse_args(argv) + + if args.print_pyqual_stage: + print( + json.dumps( + {"custom_tools": [PYQUAL_TOOL], "stages": [PYQUAL_STAGE]}, + indent=2, + ) + ) + return 0 + + root = args.root.expanduser().resolve() + config_path = resolve_config(root, args.config) + config = load_yaml(config_path) + if config.get("schema") != SCHEMA: + print(f"unsupported worktree-guard schema: {config.get('schema')}", file=sys.stderr) + return 2 + + pipeline = config.get("pipeline") or {} + triggers = pipeline.get("triggers") or [] + configured_interval = DEFAULT_INTERVAL + for trigger in triggers: + if trigger.get("kind") == "interval" and isinstance(trigger.get("seconds"), int): + configured_interval = trigger["seconds"] + + report = args.report.expanduser().resolve() if args.report else None + checker_path = args.checker.expanduser().resolve() if args.checker else None + if args.watch or args.interval: + interval = args.interval or configured_interval + return watch( + root, config, args.format, interval, report, checker_path, args.scope + ) + return run_once(root, config, args.format, report, checker_path, args.scope) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/worktree_overlap_check.py b/.governance/worktree_overlap_check.py new file mode 100755 index 0000000..399c6cd --- /dev/null +++ b/.governance/worktree_overlap_check.py @@ -0,0 +1,1202 @@ +#!/usr/bin/env python3 +"""Detect overlapping dirty/committed paths across sibling worktrees. + +This is the *active-development* companion to workspace_lifecycle_check.py. +That checker is terminal (leftover worktrees after merge). This one fails +closed when two or more checkouts of the same repository identity edit the +same paths, or when their IN_PROGRESS allowedPaths overlap without +conflictsWith. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +_previous_bytecode_policy = sys.dont_write_bytecode +sys.dont_write_bytecode = True +try: + try: + from ticket_activity import ActivityError, ActivityReadBatch + from ticket_activity import resolve as resolve_ticket_activity + except ModuleNotFoundError: + _activity_spec = importlib.util.spec_from_file_location( + "ticket_activity", Path(__file__).with_name("ticket_activity.py") + ) + if _activity_spec is None or _activity_spec.loader is None: + raise + _activity_module = importlib.util.module_from_spec(_activity_spec) + sys.modules[_activity_spec.name] = _activity_module + _activity_spec.loader.exec_module(_activity_module) + ActivityError = _activity_module.ActivityError + ActivityReadBatch = _activity_module.ActivityReadBatch + resolve_ticket_activity = _activity_module.resolve +finally: + sys.dont_write_bytecode = _previous_bytecode_policy + + +REPORT_SCHEMA = "new-project.worktree-overlap-report/v1" +SCP_REMOTE_RE = re.compile(r"^(?:[^@/]+@)?([^:/]+):(.+)$") +TICKET_DIRECTORY_RE = re.compile(r"^ticket-([0-9]+)$") +DEFAULT_IGNORE = ( + "TODO.md", + "project/TICKETS.md", + "project/ticket-*/**", + "node_modules/**", + ".venv/**", + "venv/**", + "__pycache__/**", + "**/__pycache__/**", +) +DEFAULT_WORKTREE_DIRNAMES = ("worktrees", ".worktrees", ".workspaces") + + +@dataclass(order=True) +class Finding: + code: str + severity: str + message: str + remediation: str + evidence: dict[str, Any] + + +@dataclass(frozen=True) +class TicketScope: + ticket: str + workstream: str | None + allowed_paths: tuple[str, ...] + conflicts_with: tuple[str, ...] + path: str + + +@dataclass(frozen=True) +class Checkout: + path: Path + common_git_dir: Path + identity: str + head: str | None + branch: str | None + dirty: bool + pending: bool + dirty_paths: tuple[str, ...] + changed_paths: tuple[str, ...] + tickets: tuple[TicketScope, ...] + activity_errors: tuple[str, ...] + + +class AuditError(RuntimeError): + """The overlap audit could not complete safely.""" + + +def load_worktrees_contract(): + """Load the exact managed Worktrees inventory without bytecode writes.""" + script = Path(__file__).resolve() + candidates = ( + script.with_name("worktree_path_check.py"), + script.parent.parent / "subprojects" / "worktrees" / "conformance.py", + ) + source = next((candidate for candidate in candidates if candidate.is_file()), None) + if source is None: + raise AuditError("the managed Worktrees conformance module is missing") + spec = importlib.util.spec_from_file_location("overlap_worktrees_contract", source) + if spec is None or spec.loader is None: + raise AuditError(f"cannot load Worktrees conformance from {source}") + module = importlib.util.module_from_spec(spec) + previous = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + sys.modules[spec.name] = module + spec.loader.exec_module(module) + except (ImportError, OSError, ValueError) as error: + raise AuditError(f"cannot load Worktrees conformance: {error}") from error + finally: + sys.dont_write_bytecode = previous + sys.modules.pop(spec.name, None) + return module + + +# git exports these into hooks. Inherited, they override `git -C ` and +# point every subprocess back at the repository being committed, which silently +# collapses the whole workspace into a single checkout and passes the gate. +GIT_SCOPE_ENV = ( + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_INDEX_FILE", + "GIT_INDEX_VERSION", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_PREFIX", + "GIT_NAMESPACE", + "GIT_CEILING_DIRECTORIES", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", +) + + +def detached_git_env() -> dict[str, str]: + return {k: v for k, v in os.environ.items() if k not in GIT_SCOPE_ENV} + + +def run_git(root: Path, *arguments: str) -> str: + try: + result = subprocess.run( + ["git", "-C", str(root), *arguments], + capture_output=True, + check=False, + timeout=20, + env=detached_git_env(), + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise AuditError(f"git failed for {root}: {error}") from error + if result.returncode != 0: + detail = (result.stderr or result.stdout).decode('utf-8', 'surrogateescape').strip() + raise AuditError(f"git {' '.join(arguments)} failed for {root}: {detail}") + # Decode explicitly: text mode would translate CR/LF inside literal -z + # filenames. A terminating NUL protects path suffixes from metadata trimming. + return result.stdout.decode('utf-8', 'surrogateescape').rstrip("\r\n") + + +def local_remote_path(root: Path, remote: str) -> Path | None: + if remote.startswith("file://"): + parsed = urlparse(remote) + return Path(parsed.path).resolve() + candidate = Path(remote).expanduser() + if candidate.is_absolute() or remote.startswith(("./", "../")): + if not candidate.is_absolute(): + candidate = root / candidate + return candidate.resolve() + return None + + +def normalized_network_remote(remote: str) -> str: + value = remote.strip().rstrip("/") + try: + parsed = urlparse(value) + port = parsed.port + except ValueError as error: + raise AuditError("Network remote has an invalid host or port") from error + if parsed.scheme and parsed.hostname: + path = parsed.path.lstrip("/") + host = parsed.hostname.lower() + default_port = {"ssh": 22, "https": 443, "http": 80, "git": 9418}.get(parsed.scheme) + if port is not None and port != default_port: + host = f"[{host}]:{port}" if ":" in host else f"{host}:{port}" + else: + match = SCP_REMOTE_RE.fullmatch(value) + if not match: + return f"remote:{value.removesuffix('.git').lower()}" + host, path = match.groups() + host = host.lower() + return f"remote:{host}/{path.removesuffix('.git').lower()}" + + +def repository_identity(root: Path, seen: set[Path] | None = None) -> str: + resolved = root.resolve() + visited = set() if seen is None else set(seen) + if resolved in visited: + raise AuditError(f"local origin cycle detected at {resolved}") + visited.add(resolved) + try: + remote = run_git(resolved, "remote", "get-url", "origin") + except AuditError as error: + if "No such remote" not in str(error): + raise + # Linked checkouts before remote creation still share a repository. + # Their different working directories must not hide competing writes. + common = Path(run_git(resolved, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve() + return f"local-repository:{common}" + local = local_remote_path(resolved, remote) + if local is not None and (local / ".git").exists(): + return repository_identity(local, visited) + if local is not None: + return f"local:{local}" + return normalized_network_remote(remote) + + +def registered_worktrees(path: Path) -> list[Path]: + worktrees: list[Path] = [] + for line in run_git(path, "worktree", "list", "--porcelain").splitlines(): + if line.startswith("worktree "): + worktrees.append(Path(line.removeprefix("worktree ")).resolve()) + return worktrees + + +def primary_checkout(checkouts: list[Checkout]) -> Checkout: + owners = [ + checkout + for checkout in checkouts + if checkout.common_git_dir == checkout.path / ".git" + ] + return min( + owners or checkouts, + key=lambda item: (len(item.path.parts), str(item.path)), + ) + + +def workspace_inventory(checkouts: list[Checkout]) -> dict[str, Any]: + """Compose owner layout classes with adopter-owned duplicate-clone evidence.""" + contract = load_worktrees_contract() + path_style = "windows" if os.name == "nt" else "posix" + clone_groups: dict[Path, list[Checkout]] = {} + identity_groups: dict[str, list[Checkout]] = {} + for checkout in checkouts: + clone_groups.setdefault(checkout.common_git_dir, []).append(checkout) + identity_groups.setdefault(checkout.identity, []).append(checkout) + + layout_entries: dict[Path, dict[str, Any]] = {} + for _, group in sorted(clone_groups.items(), key=lambda item: str(item[0])): + primary = primary_checkout(group) + try: + observed = contract.inventory( + repository=primary.identity, + repository_name=primary.path.name, + primary_checkout=str(primary.path), + registered=[ + { + "path": str(checkout.path), + "head": checkout.head, + "branch": checkout.branch, + "detached": checkout.branch is None, + } + for checkout in group + ], + path_style=path_style, + ) + except (TypeError, ValueError) as error: + raise AuditError(f"Worktrees inventory failed: {error}") from error + if observed.get("readOnly") is not True: + raise AuditError("Worktrees inventory did not declare readOnly=true") + for entry in observed["entries"]: + layout_entries[Path(entry["path"])] = entry + + authoritative_clone = { + identity: primary_checkout(group).common_git_dir + for identity, group in identity_groups.items() + } + entries: list[dict[str, Any]] = [] + for checkout in sorted(checkouts, key=lambda item: str(item.path)): + layout = layout_entries.get(checkout.path) + if layout is None: + raise AuditError(f"Worktrees inventory omitted {checkout.path}") + duplicate = checkout.common_git_dir != authoritative_clone[checkout.identity] + anomalies = list(layout["anomalies"]) + if duplicate: + anomalies.append("duplicate-clone") + entries.append({ + "path": str(checkout.path), + "identity": checkout.identity, + "branch": checkout.branch, + "head": checkout.head, + "dirty": checkout.dirty, + "classification": layout["classification"], + "layoutVersion": layout["layoutVersion"], + "ticket": layout["ticket"], + "slug": layout["slug"], + "cloneClassification": "duplicate-clone" if duplicate else "registered", + "anomalies": sorted(set(anomalies)), + }) + return {"schema": contract.SCHEMA, "readOnly": True, "entries": entries} + + +def path_ignored(relative: str, ignore: tuple[str, ...]) -> bool: + for pattern in ignore: + if _glob_covers(pattern, relative): + return True + return False + + +def _glob_to_regex(pattern: str) -> str: + return re.escape(pattern).replace(r"\*\*", "\x00").replace(r"\*", "[^/]*").replace("\x00", ".*") + + +def _glob_covers(pattern: str, path: str) -> bool: + if pattern == path: + return True + if re.fullmatch(_glob_to_regex(pattern), path): + return True + if pattern.endswith("/**"): + parent = _glob_to_regex(pattern[:-3]) + return re.fullmatch(parent, path) is not None or re.fullmatch(parent + r"/.*", path) is not None + if pattern.endswith("/*"): + parent = _glob_to_regex(pattern[:-2]) + return re.fullmatch(parent + r"/[^/]+", path) is not None + return False + + +def globs_may_overlap(first: str, second: str) -> bool: + if first == second: + return True + if _glob_covers(first, second.rstrip("*").rstrip("/")) or _glob_covers( + second, first.rstrip("*").rstrip("/") + ): + return True + first_prefix = first.split("*", 1)[0].rstrip("/") + second_prefix = second.split("*", 1)[0].rstrip("/") + if not first_prefix or not second_prefix: + return True + return first_prefix == second_prefix or first_prefix.startswith( + second_prefix + "/" + ) or second_prefix.startswith(first_prefix + "/") + + +def default_branch(path: Path) -> str: + try: + remote_head = run_git( + path, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD" + ) + if remote_head.startswith("origin/"): + return remote_head.removeprefix("origin/") + except AuditError: + pass + for conventional in ("main", "master"): + try: + run_git(path, "rev-parse", "--verify", f"refs/heads/{conventional}") + return conventional + except AuditError: + continue + return "main" + + +def nul_records(output: str) -> list[str]: + """Require complete Git -z framing without normalizing path content.""" + if not output: + return [] + if not output.endswith('\0'): + raise AuditError('Git path output has an unterminated NUL record') + records = output.split('\0')[:-1] + if any(not record for record in records): + raise AuditError('Git path output contains an empty record') + return records + + +def dirty_paths(path: Path, ignore: tuple[str, ...]) -> tuple[str, ...]: + names: set[str] = set() + records = iter(nul_records(run_git( + path, "status", "--porcelain=v1", "-z", "--untracked-files=all"))) + for record in records: + if (len(record) < 4 or record[2] != ' ' + or any(status not in ' MTADRCU?!' for status in record[:2])): + raise AuditError('Git status contains a malformed porcelain record') + names.add(record[3:]) + if 'R' in record[:2] or 'C' in record[:2]: + # Porcelain -z emits destination, then original path. Both reserve + # the change, including names with whitespace or a literal arrow. + original = next(records, None) + if original is None: + raise AuditError('Git status is missing a rename/copy source path') + names.add(original) + return tuple(sorted(name for name in names if name and not path_ignored(name, ignore))) + + +def committed_against( + path: Path, base_ref: str, ignore: tuple[str, ...] +) -> tuple[str, ...]: + """Paths this checkout has committed since `base_ref`.""" + try: + raw = run_git(path, "diff", "--name-only", "-z", "--no-renames", f"{base_ref}..HEAD") + except AuditError: + return () + return tuple( + sorted( + { + line + for line in nul_records(raw) + if line and not path_ignored(line, ignore) + } + ) + ) + + +def merge_base(path: Path, left: str, right: str) -> str | None: + try: + return run_git(path, "merge-base", left, right) or None + except AuditError: + return None + + +def is_ancestor(path: Path, older: str, newer: str) -> bool: + try: + run_git(path, "merge-base", "--is-ancestor", older, newer) + return True + except AuditError: + return False + + +def merge_tree_conflicts(path: Path, left: str, right: str) -> tuple[str, ...] | None: + """Paths git itself cannot merge, or None when git cannot answer. + + Touching the same path is only a *proxy* for conflicting: two branches often + edit different regions and merge cleanly, while a stacked branch shares the + path with its own ancestor and cannot conflict at all. `git merge-tree` + performs the real merge in memory and reports exactly what breaks, so the + verdict is ground truth rather than a heuristic. Older git without + --write-tree returns None and the caller falls back to path intersection. + """ + try: + result = subprocess.run( + ["git", "-C", str(path), "merge-tree", "--write-tree", "--name-only", "-z", left, right], + capture_output=True, + check=False, + timeout=60, + env=detached_git_env(), + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode == 0: + return () + if result.returncode != 1: + return None + records = result.stdout.split(b"\x00") + conflicted: list[str] = [] + for record in records[1:]: + if not record: + break + conflicted.append(record.decode("utf-8", errors="surrogateescape")) + return tuple(sorted(set(conflicted))) + + +def pending_main_imports(path: Path) -> set[str]: + """Clean staged imports from the current origin default branch, if proven. + + An unfinished merge exposes already integrated main content as index edits. + It is not a competing contribution. Keep reporting that dirty state, but + exclude it from overlap attribution only when all local Git reads agree. + No fetch or index mutation is needed; unknown or older merge heads retain + conservative behavior. Committed feature edits are never exempted. + """ + try: + incoming = run_git(path, "rev-parse", "--verify", "MERGE_HEAD") + merge_file = Path(run_git(path, "rev-parse", "--path-format=absolute", "--git-path", "MERGE_HEAD")) + if merge_file.read_text(encoding="ascii").splitlines() != [incoming]: + return set() # Octopus merges have more than one source of edits. + remote = run_git(path, "rev-parse", "--verify", + f"refs/remotes/origin/{default_branch(path)}") + if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", incoming) or incoming != remote: + return set() + base = run_git(path, "merge-base", "HEAD", incoming) + + def names(*args: str) -> set[str]: + return set(run_git(path, *args).split("\0")) - {""} + + staged = names("diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD") + different = names("diff", "--cached", "--name-only", "--no-renames", "-z", incoming) + unstaged = names("diff", "--name-only", "--no-renames", "-z") + untracked = names("ls-files", "--others", "--exclude-standard", "-z") + local_commits = names("diff", "--name-only", "--no-renames", "-z", base, "HEAD") + # diff --cached includes unresolved paths; retain an explicit check so + # equality can never be inferred from a non-stage-zero index entry. + unresolved = {entry.split("\t", 1)[1] + for entry in names("ls-files", "--unmerged", "-z")} + return staged - different - unstaged - untracked - local_commits - unresolved + except (AuditError, IndexError, OSError, UnicodeError): + return set() + + +def changes_against_shared_default(first, second, first_dirty, second_dirty, first_changes, second_changes): + shared_default = False + try: + first_default = run_git(first.path, "rev-parse", "--verify", + f"refs/remotes/origin/{default_branch(first.path)}^{{commit}}") + second_default = run_git(second.path, "rev-parse", "--verify", + f"refs/remotes/origin/{default_branch(second.path)}^{{commit}}") + if first_default == second_default: + first_base = run_git(first.path, "merge-base", first.head, first_default) + second_base = run_git(second.path, "merge-base", second.head, second_default) + first_committed = set(nul_records(run_git(first.path, "diff", "--name-only", "-z", "--no-renames", first_base, first.head))) + second_committed = set(nul_records(run_git(second.path, "diff", "--name-only", "-z", "--no-renames", second_base, second.head))) + first_renames = nul_records(run_git(first.path, "diff", "--name-only", "-z", "--find-renames", + "--diff-filter=R", first_base, first.head)) + second_renames = nul_records(run_git(second.path, "diff", "--name-only", "-z", "--find-renames", + "--diff-filter=R", second_base, second.head)) + # Replace both sides only after every strict Git read succeeds. + # Rename/directory-rename conflicts can be reported at a path + # edited under another name. Preserve the conservative path + # model until attribution can follow those identities too. + if not first_renames and not second_renames: + # The default-base comparison removes inherited main changes; + # it must not reintroduce feature commits shared by both HEADs. + # Pair-relative paths exclude those commits after strict reads. + # On unreadable pair history they retain the conservative input. + first_committed &= first_changes + second_committed &= second_changes + first_changes = first_committed | first_dirty + second_changes = second_committed | second_dirty + shared_default = True + except AuditError: + pass + return first_changes, second_changes, shared_default + + +def contested_paths( + first: "Checkout", second: "Checkout", ignore: tuple[str, ...] +) -> tuple[str, ...]: + """Paths these two checkouts genuinely contend for. + + Prefer each writer's contribution relative to the same observed origin + default-branch revision. A pair's older common ancestor includes main's + history in a fresh writer, even when that writer edits unrelated files. + Intersect with pair-relative contributions so shared feature history is + excluded too. Dirty paths and conservative attribution remain independent. + Missing or divergent observations retain the common-ancestor fallback. + """ + first_dirty = set(first.dirty_paths) - pending_main_imports(first.path) + second_dirty = set(second.dirty_paths) - pending_main_imports(second.path) + first_changes, second_changes = set(first.changed_paths), set(second.changed_paths) + shared_default = False + if first.head and second.head: + base = first.head if first.head == second.head else merge_base(first.path, first.head, second.head) + if base: + # Use strict reads here: committed_against's best-effort empty + # result must not turn an unreadable peer into permission to write. + try: + first_committed = set(nul_records(run_git(first.path, "diff", "--name-only", "-z", base, first.head))) + second_committed = set(nul_records(run_git(second.path, "diff", "--name-only", "-z", base, second.head))) + first_changes = first_committed | first_dirty + second_changes = second_committed | second_dirty + except AuditError: + pass + first_changes, second_changes, shared_default = changes_against_shared_default( + first, second, first_dirty, second_dirty, first_changes, second_changes + ) + dirty_overlap = (first_dirty & second_changes) | (second_dirty & first_changes) + conflicts: set[str] = set() + if first.head and second.head and first.head != second.head: + if not is_ancestor(first.path, first.head, second.head) and not is_ancestor( + first.path, second.head, first.head + ): + reported = merge_tree_conflicts(first.path, first.head, second.head) + if reported is None: + # No usable merge-tree: fall back to the path-intersection proxy. + conflicts = first_changes & second_changes + else: + conflicts = set(reported) + if shared_default: + # A branch can conflict with main without contending with + # this particular peer. Keep the conflict in its inventory, + # but require contributions from both writers for pairing. + conflicts &= first_changes & second_changes + return tuple( + sorted( + name + for name in dirty_overlap | conflicts + if not path_ignored(name, ignore) + ) + ) + + +def changed_paths(path: Path, ignore: tuple[str, ...]) -> tuple[str, ...]: + """Everything this checkout has touched relative to the default branch. + + Used for reporting and for attributing a ticket to the checkout writing it, + never for deciding whether two checkouts collide. + """ + names: set[str] = set(dirty_paths(path, ignore)) + branch = default_branch(path) + base = None + for candidate in (f"origin/{branch}", branch): + base = merge_base(path, "HEAD", candidate) + if base: + break + if base: + names.update(committed_against(path, base, ignore)) + return tuple(sorted(name for name in names if name and not path_ignored(name, ignore))) + + +def active_statuses(root: Path) -> set[str]: + for candidate in (root / ".governance/manifest.json", root / "governance/manifest.hub.json"): + if not candidate.is_file(): + continue + try: + value = json.loads(candidate.read_text(encoding="utf-8")) + statuses = value["ticket"]["activeStatuses"] + except (OSError, json.JSONDecodeError, KeyError, TypeError) as error: + raise AuditError(f"ticket status registry is invalid: {error}") from error + if not isinstance(statuses, list) or not statuses or not all( + isinstance(item, str) and item for item in statuses + ): + raise AuditError("ticket status registry has no valid activeStatuses") + return set(statuses) + # A repository without the ticket contract still receives physical dirty + # path protection. It simply contributes no intent-level ticket scopes. + return set() + + +def virtual_ticket_files(root): + storage = run_git(root, "config", "--local", "--default", "files", "--get", "new-project.ticketStorage") + virtual = None + if storage == "sqlite": + try: + spec = importlib.util.spec_from_file_location("worktree_ticket_input", Path(__file__).with_name("ticket_input.py")) + module = importlib.util.module_from_spec(spec) + previous = sys.dont_write_bytecode + try: + sys.dont_write_bytecode = True + spec.loader.exec_module(module) + finally: + sys.dont_write_bytecode = previous + virtual = {item["ticket"]: item["files"] for item in module.configured_records(root)} + except Exception as error: + raise AuditError("configured SQLite ticket scopes are unavailable") from error + elif storage != "files": + raise AuditError("unknown ticket storage mode") + return virtual + + +def ticket_status_override(virtual, directory): + override = {} + if virtual is not None: + try: + text = virtual[directory.name]["README.md"][0].decode("utf-8") + match = re.search(r"(?mi)^-[ \t]+\*\*Status\*\*:[ \t]*([A-Z_]+)[ \t]*$", text) + if match is None: + raise ValueError("ticket status missing") + override = {"status_override": match.group(1)} + except (KeyError, ValueError) as error: + raise AuditError("configured SQLite ticket status is invalid") from error + return override + + +def scope_intent(directory, virtual): + intent_path = directory / "intent.json" + try: + raw = virtual[directory.name]["intent.json"][0] if virtual is not None else intent_path.read_bytes() + value = json.loads(raw.decode("utf-8")) + except (OSError, ValueError, KeyError) as error: + raise AuditError(f"Active ticket intent is unavailable or invalid: {directory.name}") from error + if not isinstance(value, dict): + raise AuditError(f"Active ticket intent must be an object: {directory.name}") + allowed = value.get("allowedPaths") + conflicts = value.get("conflictsWith", []) + if not isinstance(allowed, list) or not allowed or not all( + isinstance(path, str) and path for path in allowed + ): + raise AuditError(f"Active ticket intent requires valid allowedPaths: {directory.name}") + if not isinstance(conflicts, list) or not all( + isinstance(ticket, str) and ticket for ticket in conflicts + ): + raise AuditError(f"Active ticket intent requires valid conflictsWith: {directory.name}") + return value + + + +def ticket_scope_record(directory, intent): + allowed = intent.get("allowedPaths") + conflicts = intent.get("conflictsWith") + return TicketScope( + ticket=directory.name, + workstream=intent.get("workstream") if isinstance(intent.get("workstream"), str) else None, + allowed_paths=tuple(allowed) if isinstance(allowed, list) else (), + conflicts_with=tuple(conflicts) if isinstance(conflicts, list) else (), + path=str(directory.resolve()), + ) + +def ticket_scopes(root: Path) -> tuple[tuple[TicketScope, ...], tuple[str, ...]]: + project = root / "project" + virtual = virtual_ticket_files(root) + if virtual is None and not project.is_dir(): + return (), () + scopes: list[TicketScope] = [] + errors: list[str] = [] + statuses = active_statuses(root) + if not statuses: + return (), () + try: + with ActivityReadBatch(root): + directories = (project / name for name in virtual) if virtual is not None else project.iterdir() + for directory in sorted(directories, key=lambda item: item.name): + if (virtual is None and not directory.is_dir()) or TICKET_DIRECTORY_RE.fullmatch(directory.name) is None: + continue + override = ticket_status_override(virtual, directory) + try: + resolution = resolve_ticket_activity(root, directory, statuses, **override) + except ActivityError as error: + errors.append(f"{directory.name}: {error}") + resolution = None + if resolution is not None and not resolution.active: + continue + try: + intent = scope_intent(directory, virtual) + except AuditError as error: + # Keep the checkout and its known identity. The error + # fails its audit without blocking an unrelated identity. + errors.append(f"{directory.name}: {error}") + continue + scopes.append(ticket_scope_record(directory, intent)) + except ActivityError as error: + errors.append(str(error)) + return tuple(scopes), tuple(errors) + + +def has_pending_work(path: Path, dirty: bool) -> bool: + """Is this checkout actually a writer? + + A checkout whose HEAD is already contained in the default branch, with a + clean tree, is a leftover — merged and forgotten. It conflicts with nothing + because it is contributing nothing, and pairing it with live branches buries + the real findings. Leftovers are workspace_lifecycle_check.py's job. + """ + if dirty: + return True + branch = default_branch(path) + for candidate in (f"origin/{branch}", branch): + if merge_base(path, "HEAD", candidate) is None: + continue + return not is_ancestor(path, "HEAD", candidate) + return True + + +def inspect_checkout(path: Path, ignore: tuple[str, ...]) -> Checkout: + common = Path(run_git(path, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve() + try: + head = run_git(path, "rev-parse", "--verify", "HEAD") + except AuditError: + head = None + branch = run_git(path, "branch", "--show-current") or None + dirty = bool(run_git(path, "status", "--porcelain=v1", "--untracked-files=all")) + pending = has_pending_work(path, dirty) + scopes, activity_errors = ticket_scopes(path) if pending else ((), ()) + return Checkout( + path=path.resolve(), + common_git_dir=common, + identity=repository_identity(path), + head=head, + branch=branch, + dirty=dirty, + pending=pending, + # A leftover contributes nothing to any merge, so skip the three + # expensive reads it would only feed into comparisons that are skipped. + dirty_paths=dirty_paths(path, ignore) if pending else (), + changed_paths=changed_paths(path, ignore) if pending else (), + tickets=scopes, + activity_errors=activity_errors, + ) + + +def extra_workspace_roots(seed: Path) -> list[Path]: + roots: list[Path] = [] + for directory in (seed, seed.parent): + for name in DEFAULT_WORKTREE_DIRNAMES: + candidate = directory / name + if candidate.is_dir(): + roots.append(candidate.resolve()) + return roots + + +def workspace_candidates(workspace_root): + if not workspace_root.is_dir(): + raise AuditError(f"workspace root is not a directory: {workspace_root}") + seeds = [workspace_root.resolve(), *extra_workspace_roots(workspace_root)] + candidate_paths: set[Path] = set() + for seed in seeds: + if (seed / ".git").exists(): + candidate_paths.add(seed) + try: + children = list(seed.iterdir()) + except OSError as error: + raise AuditError(f"cannot read {seed}: {error}") from error + for child in children: + if not child.is_dir(): + continue + if (child / ".git").exists(): + candidate_paths.add(child.resolve()) + continue + try: + grandchildren = list(child.iterdir()) + except OSError: + continue + for grandchild in grandchildren: + if grandchild.is_dir() and (grandchild / ".git").exists(): + candidate_paths.add(grandchild.resolve()) + + return candidate_paths + + +def discover_checkouts(workspace_root: Path, ignore: tuple[str, ...]) -> list[Checkout]: + candidate_paths = workspace_candidates(workspace_root) + + pending = sorted(candidate_paths, key=str) + inspected: set[Path] = set() + while pending: + candidate = pending.pop(0) + if candidate in inspected: + continue + inspected.add(candidate) + try: + discovered = { + worktree + for worktree in registered_worktrees(candidate) + if worktree not in candidate_paths + } + except AuditError as error: + raise AuditError(f"Cannot inspect registered worktrees for {candidate}: {error}") from error + candidate_paths.update(discovered) + pending.extend(sorted(discovered, key=str)) + + checkouts: list[Checkout] = [] + for candidate in sorted(candidate_paths, key=str): + try: + checkouts.append(inspect_checkout(candidate, ignore)) + except AuditError as error: + raise AuditError(f"Cannot inspect checkout {candidate}: {error}") from error + return checkouts + + +def conflicts_declared(first: TicketScope, second: TicketScope) -> bool: + return first.ticket in second.conflicts_with or second.ticket in first.conflicts_with + + +def optional_code2llm_hint(paths: list[str]) -> dict[str, Any]: + binary = shutil.which("code2llm") + if binary is None: + return {"available": False} + python_paths = [path for path in paths if path.endswith(".py")] + return { + "available": True, + "binary": binary, + "pythonOverlapCount": len(python_paths), + "hint": ( + "code2llm is present; overlapping Python paths can be analyzed with " + "`code2llm -f toon --fast --no-png --no-chunk`." + ), + } + + +def branch_claims_ticket(branch: str | None, ticket: str) -> bool: + """True when a checkout's branch is the working branch of this ticket.""" + if not branch: + return False + match = TICKET_DIRECTORY_RE.fullmatch(ticket) + if match is None: + return False + number = int(match.group(1)) + return ( + re.search( + rf"(?:^|[^0-9a-z])ticket[-_/]?0*{number}(?:[^0-9]|$)", branch, re.I + ) + is not None + ) + + +def attributed_tickets(group: list[Checkout]) -> dict[Path, tuple[TicketScope, ...]]: + """Map each checkout to the tickets actually being worked on *there*. + + A merged-but-still-IN_PROGRESS ticket directory is present in every sibling + worktree of the same repository. Counting it once per checkout would pair a + ticket against itself through unrelated worktrees and name the wrong ticket + in the remediation. The working branch is the authority; a ticket whose + branch is not checked out anywhere falls back to the checkouts that are + actually writing its directory. If neither signal exists, the ticket is a + stale unclaimed copy and reserves no checkout scope. + """ + names = {scope.ticket for checkout in group for scope in checkout.tickets} + owners: dict[str, set[Path]] = {} + for name in names: + claimed = { + checkout.path + for checkout in group + if branch_claims_ticket(checkout.branch, name) + } + if not claimed: + claimed = { + checkout.path + for checkout in group + if any( + changed.startswith(f"project/{name}/") + for changed in checkout.changed_paths + ) + } + owners[name] = claimed + return { + checkout.path: tuple( + scope + for scope in checkout.tickets + if checkout.path in owners[scope.ticket] + ) + for checkout in group + } + + +def ticket_pair_findings(first, second, owned, ignore, identity, inventory_by_path, findings): + for left_ticket in owned[first.path]: + for right_ticket in owned[second.path]: + if left_ticket.ticket == right_ticket.ticket: + continue + if conflicts_declared(left_ticket, right_ticket): + continue + pairs = sorted( + { + f"{left} <-> {right}" + for left in left_ticket.allowed_paths + for right in right_ticket.allowed_paths + if globs_may_overlap(left, right) + and not path_ignored(left, ignore) + and not path_ignored(right, ignore) + } + ) + if not pairs: + continue + findings.append( + Finding( + code="GOV-WORKTREE-OVERLAP-002", + severity="error", + message=( + "IN_PROGRESS tickets in sibling worktrees claim overlapping " + "allowedPaths without conflictsWith." + ), + remediation=( + "Add conflictsWith on both intents, serialize one ticket to " + "BACKLOG/PLAN/BLOCKED, or narrow allowedPaths so they no longer overlap." + ), + evidence={ + "identity": identity, + "left": str(first.path), + "right": str(second.path), + "tickets": [left_ticket.ticket, right_ticket.ticket], + "overlappingPatterns": pairs, + "workspaceClassifications": [ + inventory_by_path[first.path], + inventory_by_path[second.path], + ], + }, + ) + ) + + +def checkout_pair_findings(first, second, owned, ignore, identity, inventory_by_path, findings): + shared = list(contested_paths(first, second, ignore)) + if shared: + findings.append( + Finding( + code="GOV-WORKTREE-OVERLAP-001", + severity="error", + message=( + "Two worktrees of the same repository are changing the same paths." + ), + remediation=( + "Stop one writer, declare conflictsWith, or move the overlapping " + "paths to a single ticket / integration workstream before merge." + ), + evidence={ + "identity": identity, + "left": str(first.path), + "right": str(second.path), + "leftBranch": first.branch, + "rightBranch": second.branch, + "overlappingPaths": shared, + "code2llm": optional_code2llm_hint(shared), + "workspaceClassifications": [ + inventory_by_path[first.path], + inventory_by_path[second.path], + ], + }, + ) + ) + ticket_pair_findings(first, second, owned, ignore, identity, inventory_by_path, findings) + + +def activity_findings(checkouts, only_identity, findings): + for checkout in checkouts: + for error in checkout.activity_errors: + # A repository-level gate must not fail on someone else's policy + # drift (same contract as the identity-scoped overlap groups below). + if only_identity is not None and checkout.identity != only_identity: + continue + findings.append(Finding( + code="GOV-TICKET-ACTIVITY-001", + severity="error", + message="Ticket activity could not be resolved safely.", + remediation="Repair active ticket intent or reconcile the clone-external registry from protected evidence; follow error/GOV-TICKET-ACTIVITY.md.", + evidence={"checkout": str(checkout.path), "detail": error, "fallback": "remain-active"}, + )) + + +def overlap_findings( + checkouts: list[Checkout], + ignore: tuple[str, ...] = DEFAULT_IGNORE, + only_identity: str | None = None, + focus_checkout: Path | None = None, + inventory: dict[str, Any] | None = None, +) -> list[Finding]: + findings: list[Finding] = [] + if inventory is None: + inventory = workspace_inventory(checkouts) + if not isinstance(inventory, dict) or not isinstance(inventory.get("entries"), list): + raise AuditError("Worktrees inventory entries must be an array") + inventory_by_path = {} + for entry in inventory["entries"]: + if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): + raise AuditError("Worktrees inventory entry must have a path") + path = Path(entry["path"]) + if path in inventory_by_path: + raise AuditError(f"Worktrees inventory duplicates {path}") + inventory_by_path[path] = entry + for checkout in checkouts: + if checkout.path not in inventory_by_path: + raise AuditError(f"Worktrees inventory omitted {checkout.path}") + activity_findings(checkouts, only_identity, findings) + groups: dict[str, list[Checkout]] = {} + for checkout in checkouts: + groups.setdefault(checkout.identity, []).append(checkout) + + for identity, group in sorted(groups.items()): + if len(group) < 2: + continue + # Sibling repositories still have to be *discovered* — that is how a + # worktree parked outside its own tree is found — but a repository-level + # gate must not fail on someone else's conflict. + if only_identity is not None and identity != only_identity: + continue + ordered = sorted(group, key=lambda item: str(item.path)) + owned = attributed_tickets(ordered) + for index, first in enumerate(ordered): + if not first.pending: + continue + for second in ordered[index + 1 :]: + if not second.pending: + continue + if focus_checkout is not None and focus_checkout not in { + first.path.resolve(), + second.path.resolve(), + }: + continue + checkout_pair_findings(first, second, owned, ignore, identity, inventory_by_path, findings) + return findings + + +def report_payload( + findings: list[Finding], + checkouts: list[Checkout], + only_identity: str | None = None, + inventory: dict[str, Any] | None = None, +) -> dict[str, Any]: + groups: dict[str, int] = {} + for checkout in checkouts: + groups[checkout.identity] = groups.get(checkout.identity, 0) + 1 + return { + "schema": REPORT_SCHEMA, + "status": "passed" if not findings else "failed", + "scope": only_identity or "workspace", + "inventory": inventory or { + "schema": None, + "readOnly": True, + "entries": [], + }, + "summary": { + "errors": sum(1 for item in findings if item.severity == "error"), + "warnings": sum(1 for item in findings if item.severity == "warning"), + "findings": len(findings), + "checkouts": len(checkouts), + "pendingCheckouts": sum(1 for item in checkouts if item.pending), + "identitiesWithMultipleWorktrees": sorted( + identity for identity, count in groups.items() if count > 1 + ), + }, + "findings": [asdict(item) for item in findings], + } + + +def render_text(payload: dict[str, Any]) -> str: + lines: list[str] = [] + for finding in payload["findings"]: + evidence = json.dumps( + finding["evidence"], + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ) + lines.append(f"{finding['code']} {finding['severity'].upper()}: {finding['message']} [{evidence}]") + lines.append(f" remediation: {finding['remediation']}") + summary = payload["summary"] + label = "GOV-WORKTREE-OVERLAP-PASS" if payload["status"] == "passed" else "GOV-WORKTREE-OVERLAP-FAIL" + identities = ",".join(summary["identitiesWithMultipleWorktrees"]) or "-" + lines.append( + f"{label}: {payload['status']} " + f"({summary['errors']} errors, {summary['warnings']} warnings, " + f"{summary['checkouts']} checkouts, scope={payload['scope']}, " + f"multi={identities})" + ) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace-root", required=True, type=Path) + parser.add_argument( + "--identity-of", + type=Path, + default=None, + help=( + "Report only on the repository identity of this checkout. " + "Use for a repository-level gate; omit for a workspace scan." + ), + ) + parser.add_argument( + "--focus-checkout", + type=Path, + default=None, + help=( + "Report only conflicts involving this checkout. Use for a local " + "commit gate; omit for a repository-wide or workspace audit." + ), + ) + parser.add_argument("--format", choices=("text", "json"), default="text") + parser.add_argument( + "--ignore", + action="append", + default=[], + help="Additional gitignore-style relative path to ignore (repeatable).", + ) + args = parser.parse_args(argv) + + ignore = tuple(dict.fromkeys((*DEFAULT_IGNORE, *args.ignore))) + only_identity: str | None = None + try: + if args.identity_of is not None: + only_identity = repository_identity(args.identity_of.expanduser().resolve()) + focus_checkout = ( + args.focus_checkout.expanduser().resolve() + if args.focus_checkout is not None + else None + ) + checkouts = discover_checkouts(args.workspace_root.expanduser().resolve(), ignore) + inventory = workspace_inventory(checkouts) + findings = overlap_findings( + checkouts, ignore, only_identity, focus_checkout, inventory + ) + except AuditError as error: + findings = [ + Finding( + code="GOV-WORKTREE-OVERLAP-003", + severity="error", + message="The worktree overlap audit could not be completed safely.", + remediation="Repair repository metadata or narrow --workspace-root.", + evidence={"reason": str(error)}, + ) + ] + checkouts = [] + inventory = { + "schema": None, + "readOnly": True, + "entries": [], + } + + payload = report_payload(findings, checkouts, only_identity, inventory) + if args.format == "json": + print(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"))) + else: + print(render_text(payload)) + return 0 if payload["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/worktree_path_check.py b/.governance/worktree_path_check.py new file mode 100755 index 0000000..88da583 --- /dev/null +++ b/.governance/worktree_path_check.py @@ -0,0 +1,583 @@ +"""Planner, validator and read-only inventory for wellmanifest.worktrees/v5.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from collections import Counter +from collections.abc import Callable, Iterable +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath +from typing import Any + +SCHEMA = "wellmanifest.worktrees/v5" +MINIMUM_GIT_VERSION = "2.51.0" +NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +TICKET_RE = re.compile(r"^ticket-([0-9]{3,})$") +STEM_RE = re.compile(r"^(ticket-[0-9]{3,})--([a-z0-9]+(?:-[a-z0-9]+)*)$") +BRANCH_RE = re.compile(r"^(?:refs/heads/)?ticket/([0-9]{3,})-([a-z0-9]+(?:-[a-z0-9]+)*)$") + + +def _path_type(style: str): + if style == "posix": + return PurePosixPath + if style == "windows": + return PureWindowsPath + raise ValueError("pathStyle must be 'posix' or 'windows'") + + +def _validate_segment(label: str, value: str) -> None: + if not NAME_RE.fullmatch(value): + raise ValueError(f"{label} must contain lowercase ASCII words separated by hyphens") + + +def _validate_repository_name(repository_name: str) -> None: + if ( + not isinstance(repository_name, str) + or not repository_name + or repository_name in {".", ".."} + or any(character in repository_name for character in ("/", "\\", "\0")) + ): + raise ValueError("repositoryName must be an observed repository basename") + + +def plan( + *, + repository: str, + repository_name: str, + ticket: str, + slug: str, + primary_checkout: str, + path_style: str = "posix", +) -> dict[str, str]: + """Return the canonical v5 layout record for one delivery unit.""" + _validate_repository_name(repository_name) + _validate_segment("slug", slug) + ticket_match = TICKET_RE.fullmatch(ticket) + if not ticket_match: + raise ValueError("ticket must match ticket-NNN with at least three digits") + + path_type = _path_type(path_style) + primary = path_type(primary_checkout) + if not primary.is_absolute(): + raise ValueError("primaryCheckout must be absolute") + stem = f"{ticket}--{slug}" + worktrees_root = primary / ".worktrees" + lease_root = primary / ".subactor" / "leases" + return { + "schema": SCHEMA, + "kind": "layout-record", + "repository": repository, + "repositoryName": repository_name, + "ticket": ticket, + "slug": slug, + "branch": f"ticket/{ticket_match.group(1)}-{slug}", + "pathStyle": path_style, + "primaryCheckout": str(primary), + "worktreesRoot": str(worktrees_root), + "worktreePath": str(worktrees_root / stem), + "leaseRoot": str(lease_root), + "leasePath": str(lease_root / f"{stem}.json"), + "linkMode": "relative", + "minimumGitVersion": MINIMUM_GIT_VERSION, + } + + +def validate_layout(record: dict[str, Any]) -> list[str]: + """Return stable layout errors; an empty list means exact conformance.""" + required = ( + "repository", + "repositoryName", + "ticket", + "slug", + "primaryCheckout", + "pathStyle", + ) + missing = [name for name in required if not isinstance(record.get(name), str)] + if missing: + return [f"missing_or_invalid:{name}" for name in missing] + try: + expected = plan( + repository=record["repository"], + repository_name=record["repositoryName"], + ticket=record["ticket"], + slug=record["slug"], + primary_checkout=record["primaryCheckout"], + path_style=record["pathStyle"], + ) + except ValueError as exc: + return [f"invalid_input:{exc}"] + + errors = [] + for key, expected_value in expected.items(): + if record.get(key) != expected_value: + errors.append(f"noncanonical:{key}") + extra = sorted(set(record) - set(expected)) + errors.extend(f"unexpected:{key}" for key in extra) + return errors + + +def validate(record: dict[str, Any]) -> list[str]: + """Validate either public v5 record kind.""" + if record.get("kind") == "layout-record": + return validate_layout(record) + if record.get("kind") == "inventory-record": + return validate_inventory(record) + return ["invalid_kind"] + + +def validate_filesystem(record: dict[str, Any]) -> list[str]: + """Reject symlinks in existing repository-local canonical components.""" + errors = validate_layout(record) + if errors: + return errors + native_style = "windows" if os.name == "nt" else "posix" + if record["pathStyle"] != native_style: + return ["filesystem_check_unsupported:pathStyle"] + + primary = Path(record["primaryCheckout"]) + checked: set[Path] = set() + for field in ("primaryCheckout", "worktreesRoot", "worktreePath", "leaseRoot", "leasePath"): + target = Path(record[field]) + try: + relative = target.relative_to(primary) + except ValueError: + return [f"filesystem_noncanonical:{field}"] + current = primary + candidates = [(current, ".")] + for part in relative.parts: + current = current / part + candidates.append((current, str(current.relative_to(primary)))) + for candidate, relative_name in candidates: + if candidate in checked: + continue + checked.add(candidate) + if candidate.is_symlink(): + errors.append(f"symlink_component:{field}:{relative_name}") + return errors + + +def _git_output(args: list[str], *, cwd: str | Path | None = None) -> bytes: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True + ).stdout + + +def parse_worktree_porcelain(raw: bytes) -> list[dict[str, Any]]: + """Parse `git worktree list --porcelain -z` without touching the filesystem.""" + records: list[dict[str, Any]] = [] + for raw_record in raw.split(b"\0\0"): + if not raw_record: + continue + record: dict[str, Any] = { + "path": None, + "head": None, + "branch": None, + "bare": False, + "detached": False, + "locked": False, + "prunable": False, + } + for raw_field in raw_record.split(b"\0"): + if not raw_field: + continue + field = raw_field.decode("utf-8", "surrogateescape") + key, _, value = field.partition(" ") + if key == "worktree": + record["path"] = value + elif key == "HEAD": + record["head"] = value + elif key == "branch": + record["branch"] = value + elif key in {"bare", "detached", "locked", "prunable"}: + record[key] = True + if isinstance(record["path"], str): + records.append(record) + return records + + +def registered_worktrees(start_path: str) -> list[dict[str, Any]]: + """Observe Git's registered worktrees; this function performs no repair.""" + raw = _git_output(["-C", start_path, "worktree", "list", "--porcelain", "-z"], cwd=None) + records = parse_worktree_porcelain(raw) + if not records: + raise ValueError("Git returned no registered worktrees") + return records + + +def resolve_primary_checkout(start_path: str) -> str: + """Resolve the primary checkout even when called from a linked worktree.""" + records = registered_worktrees(start_path) + primary = records[0] + if primary["bare"]: + raise ValueError("bare repositories do not have a primary checkout") + return str(primary["path"]) + + +def _delivery_identity( + stem: str | None, branch: str | None +) -> tuple[str | None, str | None]: + if stem: + stem_match = STEM_RE.fullmatch(stem) + if stem_match: + return stem_match.group(1), stem_match.group(2) + if branch: + branch_match = BRANCH_RE.fullmatch(branch) + if branch_match: + return f"ticket-{branch_match.group(1)}", branch_match.group(2) + return None, None + + +def _direct_child_stem(path: PurePath, root: PurePath) -> str | None: + try: + relative = path.relative_to(root) + except ValueError: + return None + return relative.name if len(relative.parts) == 1 else None + + +def _legacy_v1_stem(value, repository_name): + return value.startswith(f"{repository_name}--") and STEM_RE.fullmatch( + value[len(repository_name) + 2 :] + ) + + +def _classify_location(candidate, primary, workspace, repository_name, path_style): + stem = layout_version = None + if candidate == primary: + classification = "primary" + elif ( + value := _direct_child_stem(candidate, primary / ".worktrees") + ) and STEM_RE.fullmatch(value): + classification, layout_version, stem = "canonical-v5", "v5", value + elif ( + value := _direct_child_stem(candidate, primary / "worktrees") + ) and STEM_RE.fullmatch(value): + classification, layout_version, stem = "legacy-v4", "v4", value + elif ( + value := _direct_child_stem( + candidate, workspace / ".worktrees" / ".branches" / repository_name + ) + ) and STEM_RE.fullmatch(value): + classification, layout_version, stem = "legacy-v3", "v3", value + elif ( + value := _direct_child_stem(candidate, workspace / ".worktrees" / repository_name) + ) and STEM_RE.fullmatch(value): + classification, layout_version, stem = "legacy-v2", "v2", value + elif ( + value := _direct_child_stem(candidate, workspace / ".worktrees") + ) and _legacy_v1_stem(value, repository_name): + classification = "legacy-v1" + layout_version = "v1" + stem = value[len(repository_name) + 2 :] + elif path_style == "posix" and ( + candidate == PurePosixPath("/tmp") + or PurePosixPath("/tmp") in candidate.parents + ): + classification = "system-temp" + else: + classification = "unknown" + + return classification, layout_version, stem + + +def classify_path( + *, + path: str, + primary_checkout: str, + repository_name: str, + branch: str | None = None, + path_style: str = "posix", +) -> dict[str, Any]: + """Classify one registered path without changing or resolving it.""" + path_type = _path_type(path_style) + candidate = path_type(path) + primary = path_type(primary_checkout) + workspace = primary.parent + classification, layout_version, stem = _classify_location(candidate, primary, workspace, repository_name, path_style) + + ticket, slug = _delivery_identity(stem, branch) + normalized_branch = branch.removeprefix("refs/heads/") if branch else None + return { + "path": str(candidate), + "branch": normalized_branch, + "classification": classification, + "layoutVersion": layout_version, + "ticket": ticket, + "slug": slug, + "anomalies": [], + } + + +def _mark_duplicate_deliveries(entries): + identity_counts = Counter( + (entry["ticket"], entry["slug"]) + for entry in entries + if entry["classification"] != "primary" and entry["ticket"] and entry["slug"] + ) + branch_counts = Counter( + entry["branch"] + for entry in entries + if entry["classification"] != "primary" and entry["branch"] + ) + for entry in entries: + identity = (entry["ticket"], entry["slug"]) + if ( + entry["classification"] != "primary" + and ( + (entry["ticket"] and identity_counts[identity] > 1) + or (entry["branch"] and branch_counts[entry["branch"]] > 1) + ) + ): + entry["anomalies"].append("duplicate-delivery") + + +def inventory( + *, + repository: str, + repository_name: str, + primary_checkout: str, + registered: Iterable[dict[str, Any]], + path_style: str = "posix", +) -> dict[str, Any]: + """Build a deterministic, observation-only inventory record.""" + _validate_repository_name(repository_name) + path_type = _path_type(path_style) + primary = path_type(primary_checkout) + if not primary.is_absolute(): + raise ValueError("primaryCheckout must be absolute") + + entries = [] + for observed in registered: + path = observed.get("path") + if not isinstance(path, str): + raise TypeError("every registered worktree requires a path") + entry = classify_path( + path=path, + primary_checkout=primary_checkout, + repository_name=repository_name, + branch=observed.get("branch"), + path_style=path_style, + ) + anomalies = list(entry.get("anomalies", [])) + if observed.get("stale") or (observed.get("prunable") and not os.path.exists(path)): + if "stale-registration" not in anomalies: + anomalies.append("stale-registration") + if observed.get("merged"): + if "merged-leftover" not in anomalies: + anomalies.append("merged-leftover") + entry["anomalies"] = anomalies + entry.update( + { + "head": observed.get("head"), + "bare": bool(observed.get("bare", False)), + "detached": bool(observed.get("detached", False)), + "locked": bool(observed.get("locked", False)), + "prunable": bool(observed.get("prunable", False)), + } + ) + entries.append(entry) + + _mark_duplicate_deliveries(entries) + + classification_counts = Counter(entry["classification"] for entry in entries) + anomaly_counts = Counter(anomaly for entry in entries for anomaly in entry["anomalies"]) + return { + "schema": SCHEMA, + "kind": "inventory-record", + "repository": repository, + "repositoryName": repository_name, + "pathStyle": path_style, + "primaryCheckout": str(primary), + "readOnly": True, + "entries": entries, + "summary": { + "total": len(entries), + "classifications": dict(sorted(classification_counts.items())), + "anomalies": dict(sorted(anomaly_counts.items())), + }, + } + + +def validate_inventory(record: dict[str, Any]) -> list[str]: + """Validate a generated inventory by deterministically rebuilding it.""" + required = ("repository", "repositoryName", "primaryCheckout", "pathStyle", "entries") + missing = [name for name in required if name not in record] + if missing: + return [f"missing:{name}" for name in missing] + if not isinstance(record["entries"], list): + return ["missing_or_invalid:entries"] + observed = [ + { + "path": entry.get("path"), + "head": entry.get("head"), + "branch": entry.get("branch"), + "bare": entry.get("bare", False), + "detached": entry.get("detached", False), + "locked": entry.get("locked", False), + "prunable": entry.get("prunable", False), + "stale": "stale-registration" in entry.get("anomalies", []), + "merged": "merged-leftover" in entry.get("anomalies", []), + } + for entry in record["entries"] + if isinstance(entry, dict) + ] + try: + expected = inventory( + repository=record["repository"], + repository_name=record["repositoryName"], + primary_checkout=record["primaryCheckout"], + registered=observed, + path_style=record["pathStyle"], + ) + except (TypeError, ValueError) as exc: + return [f"invalid_input:{exc}"] + errors = [] + for key, expected_value in expected.items(): + if record.get(key) != expected_value: + errors.append(f"noncanonical:{key}") + extra = sorted(set(record) - set(expected)) + errors.extend(f"unexpected:{key}" for key in extra) + return errors + + +def _version_tuple(value: str) -> tuple[int, int, int]: + match = re.search(r"([0-9]+)\.([0-9]+)\.([0-9]+)", value) + if not match: + raise ValueError(f"cannot parse Git version: {value}") + return tuple(int(part) for part in match.groups()) + + +def feature_probe( + git: str = "git", + runner: Callable[..., subprocess.CompletedProcess[bytes]] = subprocess.run, + *, + from_worktree: str = ".", +) -> dict[str, Any]: + """Probe Git in the chosen repository, without mutating caller state.""" + # Hooks and concurrent hosts may inherit selectors for another checkout. + # The explicit cwd owns this read-only observation, not those selectors. + env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + context = runner( + [git, "-C", from_worktree, "rev-parse", "--git-dir"], + check=False, capture_output=True, env=env, + ) + context_ok = context.returncode == 0 + version_result = runner([git, "--version"], check=False, capture_output=True, env=env) + version_text = (version_result.stdout + version_result.stderr).decode( + "utf-8", "replace" + ).strip() + try: + version_ok = ( + version_result.returncode == 0 + and _version_tuple(version_text) >= _version_tuple(MINIMUM_GIT_VERSION) + ) + except ValueError: + version_ok = False + + options: dict[str, bool] = {} + for command in ("add", "repair"): + options[command] = False + if context_ok: + result = runner( + [git, "-C", from_worktree, "worktree", command, "-h"], + check=False, capture_output=True, env=env, + ) + help_text = (result.stdout + result.stderr).decode("utf-8", "replace") + options[command] = result.returncode in (0, 129) and "relative-paths" in help_text + supported = version_ok and all(options.values()) + return { + "minimumGitVersion": MINIMUM_GIT_VERSION, + "repositoryContextValid": context_ok, + "probeError": None if context_ok else "repository_context_unavailable", + "gitVersion": version_text, + "versionSupported": version_ok, + "worktreeAddRelativePaths": options["add"], + "worktreeRepairRelativePaths": options["repair"], + "supported": supported, + } + + +def _read_json(path: str) -> dict[str, Any]: + if path == "-": + return json.load(sys.stdin) + with open(path, encoding="utf-8") as handle: + return json.load(handle) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + planner = subparsers.add_parser("plan") + planner.add_argument("--repository", required=True) + planner.add_argument("--repository-name", required=True) + planner.add_argument("--ticket", required=True) + planner.add_argument("--slug", required=True) + location = planner.add_mutually_exclusive_group(required=True) + location.add_argument("--primary-checkout") + location.add_argument("--from-worktree") + planner.add_argument("--path-style", choices=("posix", "windows"), default="posix") + + validator = subparsers.add_parser("validate") + validator.add_argument("record", help="JSON file or - for stdin") + validator.add_argument( + "--check-filesystem", + action="store_true", + help="reject symlinks in existing canonical path components", + ) + + observer = subparsers.add_parser("inventory") + observer.add_argument("--repository", required=True) + observer.add_argument("--repository-name", required=True) + observer.add_argument("--from-worktree", default=".") + + probe = subparsers.add_parser("feature-probe") + probe.add_argument("--git", default="git") + probe.add_argument("--from-worktree", default=".", + help="Repository to probe; independent of the caller cwd") + + args = parser.parse_args() + if args.command == "plan": + if args.from_worktree and args.path_style != ("windows" if os.name == "nt" else "posix"): + parser.error("--from-worktree requires the native path style") + primary = args.primary_checkout or resolve_primary_checkout(args.from_worktree) + record = plan( + repository=args.repository, + repository_name=args.repository_name, + ticket=args.ticket, + slug=args.slug, + primary_checkout=primary, + path_style=args.path_style, + ) + print(json.dumps(record, indent=2)) + return 0 + + if args.command == "validate": + record = _read_json(args.record) + errors = validate_filesystem(record) if args.check_filesystem else validate(record) + print(json.dumps({"ok": not errors, "errors": errors}, indent=2)) + return 0 if not errors else 1 + + if args.command == "inventory": + primary = resolve_primary_checkout(args.from_worktree) + record = inventory( + repository=args.repository, + repository_name=args.repository_name, + primary_checkout=primary, + registered=registered_worktrees(args.from_worktree), + path_style="windows" if os.name == "nt" else "posix", + ) + print(json.dumps(record, indent=2)) + return 0 + + result = feature_probe(args.git, from_worktree=args.from_worktree) + print(json.dumps(result, indent=2)) + return 0 if result["supported"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.governance/worktrees.lock.json b/.governance/worktrees.lock.json new file mode 100644 index 0000000..30fadd6 --- /dev/null +++ b/.governance/worktrees.lock.json @@ -0,0 +1,27 @@ +{ + "schema": "new-project.worktrees-lock/v1", + "dependency": { + "id": "wellmanifest/worktrees", + "version": "0.5.4", + "sourceRepository": "wellmanifest/worktrees", + "sourceRevision": "0be9ad1387bd416fd5c1c6ee68a7b7050d5f2a3e" + }, + "artifacts": [ + { + "sourcePath": "models/worktrees.schema.json", + "packageSourcePath": "subprojects/worktrees/worktrees.schema.json", + "managedTargetPath": ".governance/worktrees.schema.json", + "sourceSha256": "6b57184758bdceda5cd5ea98889427af1a762dd0afe801ac8e1279d26ccb2cb8" + }, + { + "sourcePath": "operations/conformance.py", + "packageSourcePath": "subprojects/worktrees/conformance.py", + "managedTargetPath": ".governance/worktree_path_check.py", + "sourceSha256": "106f91e6398564b96d5487dbd981ebb2078d7d366b327400b7d08467fa04c1fd" + } + ], + "installation": { + "lockTargetPath": ".governance/worktrees.lock.json", + "networkRequired": false + } +} diff --git a/.governance/worktrees.schema.json b/.governance/worktrees.schema.json new file mode 100644 index 0000000..c6559f4 --- /dev/null +++ b/.governance/worktrees.schema.json @@ -0,0 +1,193 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wellmanifest.org/schemas/worktrees/v5", + "title": "Wellmanifest worktree records", + "oneOf": [ + {"$ref": "#/$defs/layoutRecord"}, + {"$ref": "#/$defs/inventoryRecord"} + ], + "$defs": { + "repositoryName": { + "$ref": "#/$defs/observedRepositoryName" + }, + "observedRepositoryName": { + "type": "string", + "minLength": 1, + "not": {"enum": [".", ".."]}, + "pattern": "^[^/\\\\\\u0000]+$" + }, + "ticket": { + "type": "string", + "pattern": "^ticket-[0-9]{3,}$" + }, + "slug": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "nullableTicket": { + "oneOf": [ + {"$ref": "#/$defs/ticket"}, + {"type": "null"} + ] + }, + "nullableSlug": { + "oneOf": [ + {"$ref": "#/$defs/slug"}, + {"type": "null"} + ] + }, + "nullableString": { + "oneOf": [ + {"type": "string"}, + {"type": "null"} + ] + }, + "pathStyle": { + "enum": ["posix", "windows"] + }, + "layoutRecord": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "kind", + "repository", + "repositoryName", + "ticket", + "slug", + "branch", + "pathStyle", + "primaryCheckout", + "worktreesRoot", + "worktreePath", + "leaseRoot", + "leasePath", + "linkMode", + "minimumGitVersion" + ], + "properties": { + "schema": {"const": "wellmanifest.worktrees/v5"}, + "kind": {"const": "layout-record"}, + "repository": {"type": "string", "minLength": 1}, + "repositoryName": {"$ref": "#/$defs/repositoryName"}, + "ticket": {"$ref": "#/$defs/ticket"}, + "slug": {"$ref": "#/$defs/slug"}, + "branch": { + "type": "string", + "pattern": "^ticket/[0-9]{3,}-[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "pathStyle": {"$ref": "#/$defs/pathStyle"}, + "primaryCheckout": {"type": "string", "minLength": 1}, + "worktreesRoot": {"type": "string", "minLength": 1}, + "worktreePath": {"type": "string", "minLength": 1}, + "leaseRoot": {"type": "string", "minLength": 1}, + "leasePath": {"type": "string", "minLength": 1}, + "linkMode": {"const": "relative"}, + "minimumGitVersion": {"const": "2.51.0"} + } + }, + "inventoryEntry": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "branch", + "classification", + "layoutVersion", + "ticket", + "slug", + "anomalies", + "head", + "bare", + "detached", + "locked", + "prunable" + ], + "properties": { + "path": {"type": "string", "minLength": 1}, + "branch": {"$ref": "#/$defs/nullableString"}, + "classification": { + "enum": [ + "primary", + "canonical-v5", + "legacy-v4", + "legacy-v3", + "legacy-v2", + "legacy-v1", + "system-temp", + "unknown" + ] + }, + "layoutVersion": { + "oneOf": [ + {"enum": ["v1", "v2", "v3", "v4", "v5"]}, + {"type": "null"} + ] + }, + "ticket": {"$ref": "#/$defs/nullableTicket"}, + "slug": {"$ref": "#/$defs/nullableSlug"}, + "anomalies": { + "type": "array", + "items": { + "enum": [ + "duplicate-delivery", + "stale-registration", + "merged-leftover" + ] + }, + "uniqueItems": true + }, + "head": {"$ref": "#/$defs/nullableString"}, + "bare": {"type": "boolean"}, + "detached": {"type": "boolean"}, + "locked": {"type": "boolean"}, + "prunable": {"type": "boolean"} + } + }, + "countMap": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 1 + } + }, + "inventoryRecord": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "kind", + "repository", + "repositoryName", + "pathStyle", + "primaryCheckout", + "readOnly", + "entries", + "summary" + ], + "properties": { + "schema": {"const": "wellmanifest.worktrees/v5"}, + "kind": {"const": "inventory-record"}, + "repository": {"type": "string", "minLength": 1}, + "repositoryName": {"$ref": "#/$defs/observedRepositoryName"}, + "pathStyle": {"$ref": "#/$defs/pathStyle"}, + "primaryCheckout": {"type": "string", "minLength": 1}, + "readOnly": {"const": true}, + "entries": { + "type": "array", + "items": {"$ref": "#/$defs/inventoryEntry"} + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["total", "classifications", "anomalies"], + "properties": { + "total": {"type": "integer", "minimum": 0}, + "classifications": {"$ref": "#/$defs/countMap"}, + "anomalies": {"$ref": "#/$defs/countMap"} + } + } + } + } + } +} diff --git a/.subactor/.gitignore b/.subactor/.gitignore new file mode 100644 index 0000000..ca1bf29 --- /dev/null +++ b/.subactor/.gitignore @@ -0,0 +1,7 @@ +# Managed local-runtime ignores. manifest.json remains tracked. +/leases/ +/sessions/ +/recovery/ +/receipts/ +/cache/ +/snapshots/ diff --git a/.subactor/manifest.json b/.subactor/manifest.json new file mode 100644 index 0000000..3be2e28 --- /dev/null +++ b/.subactor/manifest.json @@ -0,0 +1,26 @@ +{ + "schema": "new-project.subactor-local/v1", + "standardPin": { + "lockPath": ".governance/manifest.lock.json", + "requiredStandardId": "wellmanifest/new-project" + }, + "ignoredRuntimeDirectories": [ + ".subactor/leases/", + ".subactor/sessions/", + ".subactor/recovery/", + ".subactor/receipts/", + ".subactor/cache/", + ".subactor/snapshots/" + ], + "continuity": { + "checkpointSchema": "new-project.work-continuity/v2", + "eventSchema": "new-project.work-continuity-event/v2", + "indexSchema": "new-project.work-continuity-index/v2", + "eventStreamPath": ".subactor/sessions/work-continuity.jsonl", + "eventStreamPolicyMaxBytes": null, + "checkpointIndexPath": ".subactor/recovery/checkpoint-index.json", + "checkpointIndexMaxEntries": 128, + "checkpointIndexMaxBytes": 262144, + "checkpointIndexWrite": "atomic-replace" + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..103ce52 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,341 @@ +# AGENTS.md + + +## Managed standard sources + +This managed projection follows the local adoption contract. The local lock, +manifest and managed-file digests are authoritative; remote `main` links are +navigation only and are never fetched or executed by an agent. + +- Local adoption manifest: [.governance/manifest.json](.governance/manifest.json) +- Local adoption lock: [.governance/manifest.lock.json](.governance/manifest.lock.json) +- Local package map: [.governance/package-manifest.json](.governance/package-manifest.json) +- Canonical instructions: [AGENTS template](https://github.com/wellmanifest/new-project/blob/main/template/files/AGENTS.template.md) +- Autonomous merge standard: [AUTONOMOUS_MERGE.md](https://github.com/wellmanifest/merge/blob/main/docs/AUTONOMOUS_MERGE.md) +- Host contract: [agent-hosts.json](https://github.com/wellmanifest/new-project/blob/main/governance/agent-hosts.json) +- Immutable adoption/updater: [create_adoption_lock.py](https://github.com/wellmanifest/new-project/blob/main/scripts/create_adoption_lock.py) +- Worktree contract: [worktrees.schema.json](https://github.com/wellmanifest/worktrees/blob/main/models/worktrees.schema.json) +- Git lifecycle: [git-lifecycle.schema.json](https://github.com/wellmanifest/git-lifecycle/blob/main/standard/git-lifecycle.schema.json) +- Ticket lifecycle: [ticket-lifecycle.schema.json](https://github.com/wellmanifest/ticket-lifecycle/blob/main/standard/ticket-lifecycle.schema.json) +- Policy DSL: [POLICY_DSL.md](https://github.com/wellmanifest/policy-dsl/blob/main/spec/POLICY_DSL.md) +- Logs contract: [logs.contract.json](https://github.com/wellmanifest/logs/blob/main/contracts/logs.contract.json) +- Docs contract: [wellmanifest/docs POLICY.md](https://github.com/wellmanifest/docs/blob/main/docs/standard/POLICY.md) +- Agent contract: [agent.schema.json](https://github.com/wellmanifest/agent/blob/main/standard/agent.schema.json) +- LLM policy boundary: [wellmanifest/llm README](https://github.com/wellmanifest/llm/blob/main/README.md) +- Session recovery: [session-recovery.schema.json](https://github.com/wellmanifest/session-recovery/blob/main/standard/session-recovery.schema.json) +- Embedded SemVer & Heterogeneous Fleet: [embedded-release.schema.json](https://github.com/wellmanifest/semver/blob/main/schemas/embedded-release.schema.json) +- Offer pointer: [wellmanifest/offer README](https://github.com/wellmanifest/offer/blob/main/README.md) +- Brand pointer: [wellmanifest/brand README](https://github.com/wellmanifest/brand/blob/main/README.md) + + + +## Opted-in SQLite ticket storage + +When `git config --local --get new-project.ticketStorage` is `sqlite`, the +registered primary checkout's ignored `project.sqlite` owns ticket content. +References below to ticket README, intent, status and evidence mean records in +that database; do not create or synchronize `project/ticket-*`, TODO or indexes +for operational updates. Allocate through `project/new-ticket.sh` with the +independently pinned Registry writer configured as `new-project.ticketStoreRoot` +and `new-project.ticketStoreSha256`. Complete bounded intent in SQLite before +implementation. Read it with the managed `ticket_input.py read` command and +append changes through the Registry CLI with the expected revision. +Local hooks and scope/continuity readers honor this mode. Protected CI still +requires an independently acquired, exact-base/head snapshot and approval; +local Git configuration, a database or its digest grants neither. Keep legacy +files until a repository's protected CI adoption canary succeeds. + + +This target repository follows `wellmanifest/new-project` policy-as-code. + +HOME vs ADOPT: wellmanifest owns standards; product CLI/daemons HOME in +`subactor`, `semcod`, `paxlet-com` or `dockuri`. "w ramach wellmanifest" means ADOPT packs such as +`wellmanifest/{new-project,dsl,logs}`, not HOME wellmanifest. For +SERVICE/FEATURE that create a repo, fill `intent.json` `placement` +(`home`, `shape`, `runtimeOwner`, `adopt`) in WAIT_FOR_APPROVAL. +`shape=runtime_service` must not use `home=wellmanifest`. + +Before any multi-step implementation, an agent must: + +Run the managed `.governance/work_start_check.py --root . --workstream ` +before development or allocation; add `--ticket ticket-NNN` for continuation. +Observe registered worktrees and unintegrated branches, then prefer finishing +authorized work, read-only assistance, accepted fenced handoff or serialization. +A new ticket needs a free scope and WIP capacity. Recheck owner, intent, current +state and controller fencing before writing. `--force-new` is not a bypass. +Unknown ownership or remote/independent-clone state must not be guessed. +Run `.governance/idle_takeover.py observe` and read +`.subactor/cache/autonomy-board.md` (owner, idle time, canonical worktree path +and links to project, branch, README, PR and issues for every ticket). A ticket +with no activity for `coordination.idleTakeover.idleMinutes` is +`IDLE_TAKEOVER_ELIGIBLE`; continue it only through `idle_takeover.py takeover` +(`GOV-IDLE-TAKEOVER`). Take the ticket, branch, `worktreePath`, `leasePath` and +`writerEnvironment` only from that JSON result and work only inside +`/.worktrees/--` (wellmanifest/worktrees v5): +never create, move or hand-write a worktree or lease, never treat a linked +worktree as the primary checkout, and stop on the first non-zero exit. + +1. Read `.governance/manifest.json`, `TODO.md`, `project/TICKETS.md` and the + active ticket. + Respect `repository.mode`: `standalone` owns a separate repository, while + `monorepo` confines work to declared `repository.componentRoots`. Require a + running Docker engine and Docker runtime files only when + `docker.required=true`; existing Docker configuration remains subject to + stack validation even when Docker is optional. +2. Reuse an unfinished ticket whose workstream and scope match. A second active + ticket is allowed when its write scope is disjoint and the manifest's + per-workstream concurrency limit permits it. + Otherwise run `./project/new-ticket.sh --title "..." --agent "..." + --workstream "..."`. +3. Complete the minimal ticket `README.md` and `intent.json`. Routine disjoint + source/test work uses that compact intent; add the full `delivery` contract + for dependency manifests, integration-owned paths or repositories whose + manifest requires it. Participant prose, + changelog, raw logs, TODO and indexes are optional and never delivery output. +4. Treat a user request that already says to execute or work autonomously as + `SESSION_EXECUTION_AUTHORIZATION`; record it in the agent-owned ticket file. + When that same request explicitly creates a new repository, `HEAD` is + unborn and no implementation exists, it also authorizes exactly one local + governance seed-baseline commit. Resolve an immutable seed profile, stage + only its exact allowlist, scan for secrets, create no remote effect, then + record the real resulting `HEAD` as `delivery.acceptedBaseSha`. This narrow + exception never authorizes remote creation, push, pull request, merge, tag + or release; ordinary implementation starts only after the baseline. +5. Move to `EDIT` without a second confirmation and stay inside `intent.json` + `allowedPaths`. Ask for new authority only for destructive action, secret + access, new external coordination, or material objective expansion. + Before escalating, follow [.governance/AGENT_DECISIONS.md](.governance/AGENT_DECISIONS.md): + inspect the exact effect and current evidence, reuse existing authorization, + and prefer a bounded route that preserves unknown work. Continue disjoint + authorized work while a dependent effect waits. Shared Git history or a + quarantine label alone does not prove a competing writer or require cleanup. + A necessary question names the exact target, effect and applicable rule. + When the recorded outcome includes publication, this authorization also + permits invoking the repository's declared protected delivery process and + that process's merge after exact-head trusted approval. Do not ask for a + second chat confirmation. Session prose is never approval evidence and the + agent must not merge directly. +6. Never create or edit `project/ticket-*/user-*.md`; only its human owner or a + trusted intake boundary may do so. +7. Keep executable source/tests/scripts outside ticket directories. +8. Run the managed `./project/governance-check.sh` (or + `project\governance-check.bat` on Windows) plus the stack checks before + reporting completion. Root `project.sh` / `project.bat` are optional + target-owned seed aliases and must not be assumed to contain the gate. +9. Reuse the matching authorized ticket/worktree before allocating another. + Evaluate actual writers per repository and scope, not chat-agent count. + Do not create a ticket/worktree for read-only inspection, local checks, + receipts, checkpoints or routine continuation. A write in a second repository + has its own owner; reading it does not require adoption or a maintenance task. + Allocate only when material delivery needs isolation and no matching authorized + checkout exists. Preserve the adopted delivery profile even for one writer: + Worktrees v5 still requires a canonical linked delivery checkout. Serialize + ticket-ID allocation before new branching, then resolve the required location + with the managed `wellmanifest/worktrees` checker. Resolve the primary checkout from + Git even when allocation starts inside a linked checkout. The only + publishable linked worktree is + `/.worktrees/--` with + `linkMode=relative`; its lease is + `/.subactor/leases/--.json`. Root-ignore + `/.worktrees/` and only + `/.subactor/{leases,sessions,recovery,receipts,cache,snapshots}/`; keep + `.subactor/manifest.json` tracked. Before the first effect, feature-probe + `git worktree add --relative-paths` and + `git worktree repair --relative-paths` (minimum Git 2.51.0). When the host starts outside the target checkout, pass + `feature-probe --from-worktree ` to the adopted checker; resolve + `repository_context_unavailable` before interpreting feature support. Reject a + symlink in any existing canonical path component. Legacy v1/v2/v3/v4, + system-temporary, duplicate and unknown registrations are read-only recovery + inventory, never publishable locations. Never automatically move, repair, + delete, prune or clean them. A separately authorized exact operation first + audits dirty state, active processes and IDEs, leases, pull requests and + HEAD reachability. + Each diff must resolve to exactly one active ticket. Shared contract paths are edited only by the declared + integration workstream; `integrationTicket` coordinates work but does not + transfer path ownership. Product commercial registries (prices, + entitlements, public plan ids) and brand facades (tokens, vocabulary, + public plan names) belong in `integration.requiredForPaths`. For Subactor, + bump `subactor/offer` and/or `subactor/brand` before any portal facade + rewrite; `wellmanifest/policy-dsl` owns promo rules only. Empty + `conflictsWith` does not authorize a parallel offer or brand rewrite. +10. Only `IN_PROGRESS` reserves a workstream and write scope. `BACKLOG`, `PLAN` + 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 + 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. +11a. **USE LOCAL ONEDEV AND THE INDEPENDENT VALIDATOR.** For every repository, + follow [.governance/docs/LOCAL_CI_PUBLICATION.md](.governance/docs/LOCAL_CI_PUBLICATION.md). + This is the default for all owners; only `.governance/local-ci-publication.json` + with `scope.mode: "restricted"` narrows it, and never grants authority. + Resolve the protected repository profile, observe the current OneDev + head/base receipt and reuse any existing local reconciliation result. + Invoke the trusted `subactor/validator-agent/bin/run-local-direct-pr.sh` + with the exact repository, PR, ticket, head SHA and protected key reference; + use `--merge` only for already authorized publication. The deployed local + timer may own this invocation. GitHub Actions dispatch is a separate + transport and is not the default or an unavoidable dependency of local CI. + Do not declare publication blocked by Actions billing before checking the + local route. Retire a hosted check only through protected policy after an + equivalent deployed OneDev canary; preserve uncovered test/platform gates. + Freeze the head through review and merge. Never self-approve, write a fake + status, waive required checks or ask the human to invoke an available + Validator. Scope, pins, deployment and observed success are separate facts. + +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. +15. Configure GitHub with `delete_branch_on_merge=true`. A merged ticket branch + must disappear after merge. A PR closed without merge keeps its branch until + the owner explicitly discards that unmerged work. When no PR is open, the + only remote branch is the default branch. +15a. Before proposing unmerged branch discard, follow + `.governance/docs/BRANCH_INTENT_RECONCILIATION.md`. Preserve restorable + history and reconcile every accepted criterion against the current target + SHA. Record implementation, partial, superseded, missing or unknown with + evidence; preserve remaining work in a linked ticket or an explicit owner + decision. Run `.governance/branch_intent_reconciliation.py` with an + independently acquired observation. Report validity never grants deletion + authority; unknown evidence blocks automatic resolution. Recheck exact refs, + digests and authority immediately before any separately authorized effect. +16. At merge, publication or explicit pilot discard, inventory temporary linked + worktrees, duplicate clones and non-default local branches. Verify dirty state and HEAD reachability + before removal; preserve unknown or unique data. Remove an exact linked + worktree through Git, prune its metadata and only then delete its released + disposable branch. Prefer recoverable trash for a verified duplicate clone. + The checker is read-only; during active work exempt a branch only through + the exact allowlisted checkout path, never a pattern or branch name. Run the + adopted workspace lifecycle checker through Goal for the terminal audit. CI + validates GitHub state separately and cannot inspect a developer filesystem. +17. Allocate every ticket ID only through `./project/new-ticket.sh` using + local and already-fetched remote refs. Fetch/prune only when explicitly + requested via `--refresh-remote` (C-CONCURRENCY-002). + Never create or copy `project/ticket-{NNN}` manually; the + clone-wide lock and high-water reservation must exist before commit. +18. Keep an implementation ticket `IN_PROGRESS / PUBLICATION` through + exact-head review and trusted merge. The protected delivery controller closes + it through an external receipt; never create a repository closure commit, + branch or PR. +19. Resolve `GOV-*` findings through `.governance/diagnostics.json` and its + linked `.governance/error/*.md` runbook when present. Ticket logs are + historical evidence and never authorize bypassing a fail-closed gate. +20. Keep each incident-specific `remediation-intent.dsl.json` in its target + ticket. Validate it, atomically render its declared task/TODO paths and run + `verify-todo2code` before extraction. Analyze todo2code with the exact graph, + diagnostics and plans so only records citing those projections can affect + the digest-bound advisory overlay; never let todo2code or an LLM expand the + accepted intent. +21. When `.governance/manifest.json` selects `domainContracts.mode=cqrs`, keep + command and query definitions only in `operations/index.json`. Publish the + mandatory `events/index.json` and `error/index.json` catalogs with stable + `events/{event-id}.md` and `error/{code}.md` documents. Protobuf and JSON + Schema models describe transport shape only; they never grant authority or + redefine C/Q semantics. Run the managed gate after every graph change. +22. Host-agnostic standard: follow `GEMINI.md`, `CLAUDE.md`, and + `.cursor/rules/new-project-standard.mdc` in addition to this file. Run + `./scripts/install-agent-hosts.sh` once per clone so `.githooks/pre-commit` + rejects commits that are not bound to an `IN_PROGRESS` `ticket-NNN`. Do + not write on `main` or a dirty primary checkout. Markdown is not a + substitute for the hook. +23. Require material delivery: ticket directories, TODO, ticket indexes and a + generated artifact registry are tracking carriers, not an outcome. Reject a + carrier-only commit or PR. If analysis finds no material delta, emit an + external no-change receipt and create no repository history. Intent may be + committed atomically with the first material change; do not create a + separate plan-only commit. A version bump and release projections join + their material implementation ticket; never create a separate release-only + ticket, branch or PR. +24. Treat conversation memory as a cache, never task storage. At a material + milestone, configured checkpoint interval, context compaction, handoff, + pause, blocker, tool failure or external-effect boundary, emit a bounded + `new-project.work-continuity/v2` checkpoint through + `.governance/work_continuity.py`. Append it to the ignored host-agnostic + session event stream and atomically refresh the bounded recovery index; + event streams have no policy size cap. Persist its receipt externally for + cross-machine recovery. Bind the exact plan, slice, ticket, branch, HEAD, + lease, remote/account observation and snapshot receipt. Resume by observing + Git/PR/receipts first, verifying the monotonic chain, intent, HEAD and + workspace digest, then revalidating lease and remote account. Checkpoint + data and prose grant no authority. Dirty work needs an authorized + ticket-branch commit or a content-addressed, secret-scanned external + snapshot. Pre-commit checks only the local immutable pin; explicit + adoption/updater automation owns freshness and the hook never fetches or + mutates. + +25. Apply proportional evidence through + `.governance/decision_record.py classify-action --action `. + Routine in-scope edits, formatting and local checks use the existing intent, + diff and check report. They do not require a new decision record. Never + generate APPROVE or REQUEST_CHANGES from a local PASS/FAIL; a valid legacy + record is not trusted review. Material scope/authority, destructive and + publication decisions retain recomputable evidence and independent control. + Finalize tracked carriers and format checks before snapshot/checkpoint and + lease release. Reuse the matching lease; coalesce same-boundary checkpoint + triggers. Do not recursively log the act of writing evidence. Read-only + inspection and external receipt writes do not acquire repository write leases. + +Markdown approval is an audit note, not trusted merge approval. Required +merge approval comes from the repository's protected review, attestation and +ruleset boundary. + +## Project-bound development execution + +Before running Python or pytest, select the independently approved full Wellman +runtime and verify its source pin and `run --help` capability. Use +`/bin/wellman run --root -- python -m pytest ...` +or the executor's enrolled SDK adapter. Reuse the canonical `wellman.execution` +implementation; do not copy admission logic into project scripts. The static +checker is the separate `wellman-governance` command and cannot mediate a child +process. Never select a runtime merely because a command named `wellman` is on PATH. + +The child cwd, candidate source, interpreter association and development +temp/cache must bind to that Git checkout. Prepare its local `.venv` first. +Reuse of the same repository's registered primary `.venv` must be explicit; +another project's mutable environment is not a fallback. Development scratch +belongs in the checkout's ignored private `.subactor/cache/`. Immutable tools, +protected CI scratch and external receipt stores require distinct declared roles, +pins and ownership; their location alone neither grants nor denies authority. + +Missing enrollment or a missing/mismatched execution receipt is a prerequisite +to repair, never evidence of successful execution. Preserve the real command, +stdout/stderr and exit status. A static governance PASS, a local execution +observation or these instructions do not establish universal shell/IDE mediation, +a filesystem sandbox, a writer lease or independent publication approval. + +## Bounded session controls + +Every implementation session is bounded by the ticket's `maxActiveMinutes` and +must create a `checkpoint` before a context, tool or process boundary. On a +deterministic failure or unresolved ownership, write a concise `handoff` with +the next observable action and `stop`; do not retry the same failed path +indefinitely. + + +## Protected delivery and continuity + +Passing tests is necessary, but never grants review or merge authority. +When publication is authorized, invoke the repository's declared protected +delivery controller and independent Validator. Approval must bind the exact +repository, PR, HEAD, ticket and trusted actor; only the protected controller +may merge after all required gates and trusted approval succeed. + +Never self-approve, rotate reviewer accounts, bypass branch protection, or +use an administrative token or browser session to evade a review or API limit. +On quota exhaustion, preserve the exact-head checkpoint and signed review +progress, then resume the same authorized transport after the limit resets. +A model verdict, local PASS, or Markdown instruction is not approval evidence. + +If a conflicting upstream change requires rebuilding the ticket, revalidate +ownership and intent first, then obtain fresh validation for the resulting +HEAD. Keep the reviewed HEAD frozen through approval and merge. + +Review and publication retain the declared WIP limits. Cleanup requires +verified integration, released ownership, and an exact-path audit of dirty +state and HEAD reachability. Preserve unknown or unique data; never force +removal merely to clear a governance lock. + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..da12b41 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +See the repository Git history for published changes. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d3946b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,40 @@ +# CLAUDE.md + + +## Managed standard sources + +Read the local adoption manifest, lock and package before using this host +projection. The remote links are navigation only; do not fetch them at runtime. + +- Local adoption manifest: [.governance/manifest.json](.governance/manifest.json) +- Local adoption lock: [.governance/manifest.lock.json](.governance/manifest.lock.json) +- Local package map: [.governance/package-manifest.json](.governance/package-manifest.json) +- Canonical instructions: [AGENTS template](https://github.com/wellmanifest/new-project/blob/main/template/files/AGENTS.template.md) +- Host contract: [agent-hosts.json](https://github.com/wellmanifest/new-project/blob/main/governance/agent-hosts.json) +- Immutable adoption/updater: [create_adoption_lock.py](https://github.com/wellmanifest/new-project/blob/main/scripts/create_adoption_lock.py) + + + +This repository follows the `wellmanifest/new-project` policy-as-code standard. +Same contract as `AGENTS.md`, `GEMINI.md`, `.cursor/rules/new-project-standard.mdc`, +`.aider.conf.yml` and `.github/copilot-instructions.md`. Claude Code must follow +it even when the session did not start in an IDE. + +1. Read `AGENTS.md` and `.governance/manifest.json` first. +2. Allocate tickets only through `./project/new-ticket.sh`. Never copy a + `project/ticket-NNN` directory and never invent a ticket number. +3. Work on a branch or worktree whose name contains `ticket-NNN`. Never write on + `main` or a dirty primary checkout. +4. Stay inside that ticket's `intent.json` `allowedPaths`. +5. Run `./scripts/install-agent-hosts.sh` once per clone so `.githooks/pre-commit` + is active. +6. Run `./project/governance-check.sh` before claiming done. + +The pre-commit hook rejects commits that are not bound to an `IN_PROGRESS` +`ticket-NNN`, and the `governance / enforce` CI job rejects a pull request whose +host contract or packaging declaration drifted. Markdown is not a substitute for +either gate. + +Bounded session controls: respect the ticket's `maxActiveMinutes`, create a +`checkpoint` before a context or tool boundary, and leave a `handoff` then +`stop` after a deterministic failure instead of retrying indefinitely. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..e700b4c --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,40 @@ +# GEMINI.md + + +## Managed standard sources + +The local adoption manifest, lock and package are authoritative. These remote +links are navigation only and must not be fetched or executed at runtime. + +- Local adoption manifest: [.governance/manifest.json](.governance/manifest.json) +- Local adoption lock: [.governance/manifest.lock.json](.governance/manifest.lock.json) +- Local package map: [.governance/package-manifest.json](.governance/package-manifest.json) +- Canonical instructions: [AGENTS template](https://github.com/wellmanifest/new-project/blob/main/template/files/AGENTS.template.md) +- Host contract: [agent-hosts.json](https://github.com/wellmanifest/new-project/blob/main/governance/agent-hosts.json) +- Immutable adoption/updater: [create_adoption_lock.py](https://github.com/wellmanifest/new-project/blob/main/scripts/create_adoption_lock.py) + + + +This repository follows the `wellmanifest/new-project` policy-as-code standard. +This file is the Gemini / Antigravity entry; the same rules are in `AGENTS.md`, +`CLAUDE.md`, `.cursor/rules/new-project-standard.mdc`, `.aider.conf.yml` and +`.github/copilot-instructions.md`. + +Fail-closed. Do not write code until this contract is followed. + +1. Read `AGENTS.md` and `.governance/manifest.json`. +2. Allocate tickets only through `./project/new-ticket.sh`. Never copy + `project/ticket-*`. +3. Work on a branch or worktree whose name contains `ticket-NNN`. Never commit on + `main` or a dirty primary checkout. +4. Stay inside that ticket's `intent.json` `allowedPaths`. +5. Run `./scripts/install-agent-hosts.sh` once per clone so the git hook is active. +6. Run `./project/governance-check.sh` before claiming done. + +If authority or ownership remains unclear, pause the dependent effect and +follow [.governance/AGENT_DECISIONS.md](.governance/AGENT_DECISIONS.md) to inspect evidence and existing authorization. +Continue disjoint authorized work. Do not invent a ticket number. + +Bounded session controls: respect the ticket's `maxActiveMinutes`, create a +`checkpoint` before a context or tool boundary, and leave a `handoff` then +`stop` after a deterministic failure instead of retrying indefinitely. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..2c6a707 --- /dev/null +++ b/TODO.md @@ -0,0 +1,3 @@ +# Work backlog + +Implementation work is recorded in governed tickets. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/project.bat b/project.bat new file mode 100644 index 0000000..6cfb2cc --- /dev/null +++ b/project.bat @@ -0,0 +1,35 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion +set "REPO_ROOT=%~dp0" + +if not exist "%REPO_ROOT%.governance\manifest.json" ( + echo GOV-MANIFEST-001: .governance\manifest.json is not installed in this target repository. 1>&2 + echo remediation: bootstrap the pinned governance package before implementation. 1>&2 + exit /b 1 +) +if not exist "%REPO_ROOT%project\governance-check.bat" ( + echo GOV-BOOT-001: project\governance-check.bat is missing. 1>&2 + exit /b 1 +) + +call "%REPO_ROOT%project\governance-check.bat" %* +set "GOVERNANCE_EXIT=%ERRORLEVEL%" +if not "%GOVERNANCE_EXIT%"=="0" exit /b %GOVERNANCE_EXIT% + +if not "%NEW_PROJECT_ANALYSIS_IMAGE%"=="" ( + powershell -NoProfile -Command "if ($env:NEW_PROJECT_ANALYSIS_IMAGE -notmatch '@sha256:[a-f0-9]{64}$') { exit 1 }" + if errorlevel 1 ( + echo GOV-STACK-001: NEW_PROJECT_ANALYSIS_IMAGE must be pinned by sha256 digest. 1>&2 + exit /b 1 + ) + docker info >nul 2>&1 + if errorlevel 1 ( + echo GOV-DOCKER-001: Docker engine is unavailable. 1>&2 + exit /b 1 + ) + docker run --rm --network none --mount "type=bind,src=%REPO_ROOT%,dst=/workspace" --workdir /workspace "%NEW_PROJECT_ANALYSIS_IMAGE%" + set "DOCKER_EXIT=!ERRORLEVEL!" + exit /b !DOCKER_EXIT! +) + +exit /b 0 diff --git a/project.sh b/project.sh new file mode 100755 index 0000000..49025ec --- /dev/null +++ b/project.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Safe target-repository entry point for wellmanifest/new-project governance. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +validator="$repo_root/project/governance-check.sh" + +if [[ -x "$validator" && -f "$repo_root/.governance/manifest.json" ]]; then + "$validator" "$@" +elif [[ ! -f "$repo_root/.governance/manifest.json" ]]; then + echo "GOV-MANIFEST-001: .governance/manifest.json is not installed in this target repository." >&2 + echo " remediation: bootstrap the pinned governance package before implementation." >&2 + exit 1 +else + echo "GOV-BOOT-001: project/governance-check.sh is missing or not executable." >&2 + echo " remediation: restore the wrapper from the pinned governance package." >&2 + exit 1 +fi + +# Optional analysis tools must be supplied as an explicitly pinned image. +# The governance gate above always runs first and no package is installed on the host. +if [[ -n "${NEW_PROJECT_ANALYSIS_IMAGE:-}" ]]; then + if [[ ! "$NEW_PROJECT_ANALYSIS_IMAGE" =~ @sha256:[a-f0-9]{64}$ ]]; then + echo "GOV-STACK-001: NEW_PROJECT_ANALYSIS_IMAGE must be pinned by sha256 digest." >&2 + echo " remediation: use registry/image@sha256:<64 lowercase hex characters>." >&2 + exit 1 + fi + command -v docker >/dev/null 2>&1 || { + echo "GOV-DOCKER-001: docker command is unavailable." >&2 + exit 1 + } + docker info >/dev/null + docker run --rm --network none \ + --mount "type=bind,src=$repo_root,dst=/workspace" \ + --workdir /workspace \ + "$NEW_PROJECT_ANALYSIS_IMAGE" +fi diff --git a/project/TICKETS.md b/project/TICKETS.md new file mode 100644 index 0000000..77fbc9d --- /dev/null +++ b/project/TICKETS.md @@ -0,0 +1,6 @@ +# Project tickets + + +| Ticket ID | Spec | Preprompt | Human input | Agent plans | Agent logs | Changelog | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | + diff --git a/project/governance-check.bat b/project/governance-check.bat new file mode 100644 index 0000000..12e806b --- /dev/null +++ b/project/governance-check.bat @@ -0,0 +1,15 @@ +@echo off +setlocal +set "REPO_ROOT=%~dp0.." +where python >nul 2>&1 +if errorlevel 1 ( + echo GOV-BOOT-001: python is unavailable on PATH. 1>&2 + exit /b 1 +) +if exist "%REPO_ROOT%\.governance\governance_check.py" ( + python "%REPO_ROOT%\.governance\governance_check.py" --root "%REPO_ROOT%" --manifest .governance/manifest.json --lock .governance/manifest.lock.json --stack-profiles .governance/stack-profiles.json --infer-ticket-base %* +) else ( + python "%REPO_ROOT%\scripts\governance_check.py" --root "%REPO_ROOT%" --manifest governance\manifest.hub.json --stack-profiles governance\stack-profiles.json --work-classification governance\work-classification.dsl.json --infer-ticket-base %* +) +set "GOVERNANCE_EXIT=%ERRORLEVEL%" +exit /b %GOVERNANCE_EXIT% diff --git a/project/governance-check.sh b/project/governance-check.sh new file mode 100755 index 0000000..84f5340 --- /dev/null +++ b/project/governance-check.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [ -f "$repo_root/.governance/governance_check.py" ]; then + PYTHONDONTWRITEBYTECODE=1 python3 "$repo_root/.governance/governance_check.py" \ + --root "$repo_root" \ + --manifest .governance/manifest.json \ + --lock .governance/manifest.lock.json \ + --stack-profiles .governance/stack-profiles.json \ + --infer-ticket-base \ + "$@" +else + PYTHONDONTWRITEBYTECODE=1 python3 "$repo_root/scripts/governance_check.py" \ + --root "$repo_root" \ + --manifest governance/manifest.hub.json \ + --stack-profiles governance/stack-profiles.json \ + --work-classification governance/work-classification.dsl.json \ + --infer-ticket-base \ + "$@" +fi diff --git a/project/new-ticket.sh b/project/new-ticket.sh new file mode 100755 index 0000000..6c8c9e3 --- /dev/null +++ b/project/new-ticket.sh @@ -0,0 +1,1273 @@ +#!/usr/bin/env bash +# Universal ticket scaffolder for target System X repositories. + +set -euo pipefail + +TITLE="New Task Ticket" +USERS="" +AGENT="antigravity" +WRITER_ACTOR="" +WRITER_SESSION="" +WRITER_IDENTITY_EXPLICIT=false +SEED_BASELINE_REQUEST="" +SEED_MODE=false + +if [[ "${NEW_PROJECT_WRITER_ACTOR+set}" == "set" || "${NEW_PROJECT_WRITER_SESSION+set}" == "set" ]]; then + WRITER_ACTOR="${NEW_PROJECT_WRITER_ACTOR:-}" + WRITER_SESSION="${NEW_PROJECT_WRITER_SESSION:-}" + WRITER_IDENTITY_EXPLICIT=true +fi +WORKSTREAM="" +SCOPE_ARGUMENTS=() +FORCE_NEW=false +ALLOCATION_KEY="" +ALLOCATION_RECEIPT="" +REFRESH_REMOTE=false +TICKET_STORAGE="" +STORE_ROOT="" +STORE_SHA256="" +SNAPSHOT_PROPOSAL="" +SNAPSHOT_MATERIALIZE="" +MIGRATION_AUTHORIZATION="" +MIGRATION_AUTHORIZATION_SHA256="" +RECOVER_REQUEST="" +RECOVERY_HELPER=ticket_recovery +RECOVERY_LEASE_STORE="" +WORKTREE_SLUG="" +BOOTSTRAP_ADOPTION_DIGEST="" +STANDARD_ADOPTION_PLAN="" +ADOPTION_PROJECTION="" +CALLER_CHECKOUT="$(pwd -P)" +ALLOCATOR_PACKAGE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" + +# Work classification for intent/v3. The defaults are the contract's own answer +# for an unclassified new ticket: rule W-CLASS-006 (work-request / maintenance) +# assigns SERVICE and health, and priorityDerivation.serviceDefault is P2. +# Declare --kind/--priority/--origin when the ticket is a defect or new behavior. +KIND="SERVICE" +PRIORITY="P2" +ORIGIN="health" + +usage() { + cat <<'EOF' +Usage: ./project/new-ticket.sh [options] + + -t, --title TITLE Ticket title + -a, --agent ID Agent provider/id used for ai-{ID}.md + --writer-actor ID Explicit allocating actor (or NEW_PROJECT_WRITER_ACTOR) + --writer-session ID Explicit session (or NEW_PROJECT_WRITER_SESSION) + --seed-baseline-request FILE + Exact external authority/state for one governance-only unborn seed + -w, --workstream ID Required workstream from the governance registry + --path PATTERN Repeatable owned implementation scope; persisted in intent + -u, --users IDS Compatibility input only; human files are not created + -k, --kind KIND Work kind; default SERVICE + -p, --priority P Work priority; default P2 + -o, --origin ORIGIN Work origin; default health + --allocation-key K Stable Supervisor/task correlation for registered mode + --allocation-receipt FILE + Receipt returned by the registered allocator process + --force-new Create a new ticket despite an unfinished ticket + --refresh-remote Fetch/prune origin before allocating; local refs are used by default + --storage MODE files (default) or sqlite; may come from local Git configuration + --ticket-store-root DIR + Installed Registry ticket writer package + --ticket-store-sha256 SHA + Independent digest of the complete writer package + -h, --help Show this help + --snapshot-proposal FILE + Reserve a snapshot identity; no writer or approval + --snapshot-materialize FILE + External exact proposal and controller CAS request + --migration-authorization FILE + Protected import grant (materialization only) + --migration-authorization-sha256 SHA + Independently pinned grant digest + --recover-request FILE + Explicit exact-state pre-adoption recovery request + --restore-request FILE + Exact request restoring an already reserved ticket checkout + --recovery-lease-store DIR + Existing external local controller store (recovery/restoration) + --bootstrap-adoption-digest SHA + Confirm ownership of exact first scaffold adoption delta + --standard-adoption-plan FILE + Recompute an exact published upgrade plan before local allocation + --worktree-slug SLUG + Canonical linked-worktree slug; defaults to a stable title slug + +Accepted classification values are read from the work classification contract, +not hardcoded here. The defaults are that contract's own answer for an +unclassified new ticket (rule W-CLASS-006 plus the service priority default); +declare the three explicitly for a defect or new behavior. + +Only a human may authorize --force-new. Human-owned user-*.md files must be +created and written by that human or by a trusted intake boundary. +--force-new never bypasses repository work-start admission. +EOF +} + +require_value() { + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Missing value for $1" >&2 + usage >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -t|--title) + require_value "$@" + TITLE="$2" + shift 2 + ;; + -u|--users) + require_value "$@" + USERS="$2" + shift 2 + ;; + -a|--agent) + require_value "$@" + AGENT="$2" + shift 2 + ;; + --writer-actor) + require_value "$@"; WRITER_ACTOR="$2"; WRITER_IDENTITY_EXPLICIT=true; shift 2 ;; + --writer-session) + require_value "$@"; WRITER_SESSION="$2"; WRITER_IDENTITY_EXPLICIT=true; shift 2 ;; + --seed-baseline-request) + require_value "$@"; SEED_BASELINE_REQUEST="$2"; shift 2 ;; + -w|--workstream) + require_value "$@" + WORKSTREAM="$2" + shift 2 + ;; + --path) + require_value "$@" + SCOPE_ARGUMENTS+=("--path=$2") + shift 2 + ;; + -k|--kind) + require_value "$@" + KIND="$2" + shift 2 + ;; + -p|--priority) + require_value "$@" + PRIORITY="$2" + shift 2 + ;; + -o|--origin) + require_value "$@" + ORIGIN="$2" + shift 2 + ;; + --storage) + require_value "$@"; TICKET_STORAGE="$2"; shift 2 ;; + --snapshot-proposal) + require_value "$@"; SNAPSHOT_PROPOSAL="$2"; shift 2 ;; + --snapshot-materialize) + require_value "$@"; SNAPSHOT_MATERIALIZE="$2"; shift 2 ;; + --migration-authorization) + require_value "$@"; MIGRATION_AUTHORIZATION="$2"; shift 2 ;; + --migration-authorization-sha256) + require_value "$@"; MIGRATION_AUTHORIZATION_SHA256="$2"; shift 2 ;; + --recover-request|--restore-request) + require_value "$@" + if [[ -n "$RECOVER_REQUEST" ]]; then + echo "GOV-TICKET-ALLOCATION-003: pass exactly one recovery or restoration request." >&2 + exit 5 + fi + [[ "$1" == --restore-request ]] && RECOVERY_HELPER=ticket_restoration + RECOVER_REQUEST="$2"; shift 2 ;; + --recovery-lease-store) + require_value "$@"; RECOVERY_LEASE_STORE="$2"; shift 2 ;; + --bootstrap-adoption-digest) + require_value "$@"; BOOTSTRAP_ADOPTION_DIGEST="$2"; shift 2 ;; + --standard-adoption-plan) + require_value "$@"; STANDARD_ADOPTION_PLAN="$2"; shift 2 ;; + --worktree-slug) + require_value "$@"; WORKTREE_SLUG="$2"; shift 2 ;; + --ticket-store-root) + require_value "$@"; STORE_ROOT="$2"; shift 2 ;; + --ticket-store-sha256) + require_value "$@"; STORE_SHA256="$2"; shift 2 ;; + --allocation-key) + require_value "$@" + ALLOCATION_KEY="$2" + shift 2 + ;; + --allocation-receipt) + require_value "$@" + ALLOCATION_RECEIPT="$2" + shift 2 + ;; + --force-new) + FORCE_NEW=true + shift + ;; + --refresh-remote) + REFRESH_REMOTE=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$TITLE" == *$'\n'* || "$TITLE" == *$'\r'* ]]; then + echo "Ticket title must fit on one line" >&2 + exit 2 +fi +if ! python3 - "$TITLE" <<'PY' +import sys +raise SystemExit(any(ord(character) < 32 or ord(character) == 127 for character in sys.argv[1])) +PY +then + echo "Ticket title must not contain ASCII control characters" >&2 + exit 2 +fi + +AGENT="$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')" +if [[ ! "$AGENT" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then + echo "Agent id must match [a-z0-9][a-z0-9._-]*" >&2 + exit 2 +fi + +if [[ -n "$STANDARD_ADOPTION_PLAN" ]]; then + STANDARD_ADOPTION_PLAN="$(python3 -B -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$STANDARD_ADOPTION_PLAN")" +fi + +# Allocation may be requested from a linked checkout, but the clone's primary +# checkout is the only valid authority for admission and canonical layout. Do +# not infer it from $PWD: Git's registered-worktree order is the durable +# observation, and all later relative reads intentionally happen from primary. +PRIMARY_CHECKOUT="" +if git rev-parse --git-dir >/dev/null 2>&1; then + PRIMARY_CHECKOUT="$(git worktree list --porcelain | sed -n 's/^worktree //p' | sed -n '1p')" + if [[ -z "$PRIMARY_CHECKOUT" || ! -d "$PRIMARY_CHECKOUT" || -L "$PRIMARY_CHECKOUT" ]]; then + echo "GOV-TICKET-ALLOCATION-003: registered primary checkout is unavailable or symlinked." >&2 + exit 5 + fi + PRIMARY_CHECKOUT="$(cd "$PRIMARY_CHECKOUT" && pwd -P)" + cd "$PRIMARY_CHECKOUT" +fi + +TICKET_STORAGE="${TICKET_STORAGE:-$(git config --local --get new-project.ticketStorage 2>/dev/null || true)}" +TICKET_STORAGE="${TICKET_STORAGE:-files}" +if [[ -n "$STANDARD_ADOPTION_PLAN" && ( "$TICKET_STORAGE" != files || "$REFRESH_REMOTE" == true || "$FORCE_NEW" == true || -n "$BOOTSTRAP_ADOPTION_DIGEST" || -n "$SNAPSHOT_PROPOSAL" || -n "$SNAPSHOT_MATERIALIZE" || -n "$MIGRATION_AUTHORIZATION" || -n "$MIGRATION_AUTHORIZATION_SHA256" || -n "$RECOVER_REQUEST" || -n "$RECOVERY_LEASE_STORE" ) ]]; then + echo "GOV-TICKET-ALLOCATION-003: adoption plans require exclusive local file allocation without bootstrap or recovery overrides." >&2 + exit 5 +fi +if [[ "$TICKET_STORAGE" != files && "$TICKET_STORAGE" != sqlite ]]; then + echo "GOV-TICKET-ALLOCATION-003: unknown ticket storage mode." >&2 + exit 1 +fi +# Reject incompatible snapshot inputs before any candidate executable bridge. +if [[ -n "$SNAPSHOT_PROPOSAL" || -n "$SNAPSHOT_MATERIALIZE" || -n "$MIGRATION_AUTHORIZATION" || -n "$MIGRATION_AUTHORIZATION_SHA256" ]]; then + if [[ "$TICKET_STORAGE" != files || "$REFRESH_REMOTE" == true || "$FORCE_NEW" == true || ${#SCOPE_ARGUMENTS[@]} -ne 0 || -n "$RECOVER_REQUEST" ]]; then + echo "GOV-TICKET-ALLOCATION-003: snapshot inputs require local file allocation and an exclusive bounded request." >&2 + exit 5 + fi +fi + +TICKET_STORAGE_HELPER="" +for candidate in .governance/ticket_storage.py scripts/ticket_storage.py; do + if [[ -f "$candidate" ]]; then TICKET_STORAGE_HELPER="$candidate"; break; fi +done +if [[ "$TICKET_STORAGE" == sqlite ]]; then + STORE_ROOT="${STORE_ROOT:-$(git config --local --get new-project.ticketStoreRoot 2>/dev/null || true)}" + STORE_SHA256="${STORE_SHA256:-$(git config --local --get new-project.ticketStoreSha256 2>/dev/null || true)}" + if [[ -z "$TICKET_STORAGE_HELPER" || -z "$STORE_ROOT" || -z "$STORE_SHA256" ]]; then + echo "GOV-TICKET-ALLOCATION-003: SQLite allocation requires the managed bridge and an independently pinned Registry writer." >&2 + exit 1 + fi + python3 "$TICKET_STORAGE_HELPER" verify --runtime-root "$STORE_ROOT" --runtime-sha256 "$STORE_SHA256" +fi + +governance_manifest() { + local candidate + for candidate in .governance/manifest.json .governance/manifest.base.json governance/manifest.hub.json; do + if [[ -f "$candidate" ]]; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + +if ! GOVERNANCE_MANIFEST="$(governance_manifest)"; then + echo "GOV-MANIFEST-001: governance registry not found; cannot allocate a ticket." >&2 + echo " remediation: restore .governance/manifest.json in an adopter or governance/manifest.hub.json in the hub." >&2 + exit 1 +fi + +if ! REGISTRY_VALUES="$(python3 - "$GOVERNANCE_MANIFEST" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as stream: + manifest = json.load(stream) +ticket = manifest.get("ticket") +coordination = manifest.get("coordination") +delivery = manifest.get("delivery") +statuses = ticket.get("activeStatuses") if isinstance(ticket, dict) else None +workstreams = coordination.get("workstreams") if isinstance(coordination, dict) else None +targets = delivery.get("targetBranches") if isinstance(delivery, dict) else None +if ( + manifest.get("schema") != "new-project.governance/v2" + or not isinstance(statuses, list) + or not statuses + or any(not isinstance(item, str) or not item for item in statuses) + or len(statuses) != len(set(statuses)) + or not isinstance(workstreams, dict) + or not workstreams + or any(not isinstance(item, str) or not item for item in workstreams) + or not isinstance(targets, list) + or len(targets) != 1 + or not isinstance(targets[0], str) + or not targets[0] +): + raise SystemExit(1) +for status in statuses: + print(f"status\t{status}") +for workstream in sorted(workstreams): + print(f"workstream\t{workstream}") +print(f"target\t{targets[0]}") +PY +)"; then + echo "GOV-MANIFEST-001: governance registry is invalid: $GOVERNANCE_MANIFEST" >&2 + echo " remediation: restore a valid governance/v2 ticket and coordination registry." >&2 + exit 1 +fi + +ACTIVE_STATUSES="$(printf '%s\n' "$REGISTRY_VALUES" | sed -n 's/^status[[:space:]]//p')" +WORKSTREAM_REGISTRY="$(printf '%s\n' "$REGISTRY_VALUES" | sed -n 's/^workstream[[:space:]]//p')" +TARGET_BRANCH="$(printf '%s\n' "$REGISTRY_VALUES" | sed -n 's/^target[[:space:]]//p')" + +if [[ -z "$WORKSTREAM" ]]; then + echo "Workstream is required; choose an id declared in $GOVERNANCE_MANIFEST" >&2 + exit 2 +fi + +WORKSTREAM="$(printf '%s' "$WORKSTREAM" | tr '[:upper:]' '[:lower:]')" +if [[ ! "$WORKSTREAM" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "Workstream id must match [a-z0-9][a-z0-9-]*" >&2 + exit 2 +fi +if ! grep -Fxq -- "$WORKSTREAM" <<< "$WORKSTREAM_REGISTRY"; then + echo "GOV-WORKSTREAM-001: workstream '$WORKSTREAM' is not declared in $GOVERNANCE_MANIFEST." >&2 + echo " accepted: $(printf '%s' "$WORKSTREAM_REGISTRY" | tr '\n' ' ')" >&2 + exit 1 +fi + +# Validate caller identity before any reservation, refresh or scaffold effect. +# Provider names and timestamps are not session identity. +if [[ -z "$SNAPSHOT_PROPOSAL$SNAPSHOT_MATERIALIZE$RECOVER_REQUEST" ]]; then + if [[ "$WRITER_IDENTITY_EXPLICIT" == true ]]; then + if [[ ! "$WRITER_ACTOR" =~ ^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,159}$ || + ! "$WRITER_SESSION" =~ ^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,254}$ ]]; then + echo "GOV-CHANGE-LEASE-001: allocation requires explicit valid writer actor and session." >&2 + exit 2 + fi + else + WRITER_ACTOR="agent:${AGENT}" + WRITER_SESSION="allocation-$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + fi +fi + +is_active_ticket() { + local directory="$1" resolver status arguments=() runner=() + if [[ "$TICKET_STORAGE" != sqlite ]]; then + [[ -f "$directory/README.md" ]] || return 1 + fi + for resolver in .governance/ticket_activity.py scripts/ticket_activity.py; do + [[ -f "$resolver" ]] && break + done + if [[ ! -f "$resolver" ]]; then + echo "GOV-TICKET-ACTIVITY-001: managed ticket activity resolver is missing." >&2 + echo " remediation: restore the complete pinned governance package before allocating." >&2 + exit 1 + fi + while IFS= read -r status; do + arguments+=(--active-status "$status") + done <<< "$ACTIVE_STATUSES" + if [[ "$TICKET_STORAGE" == sqlite ]]; then + runner=(python3 "$TICKET_STORAGE_HELPER" active --root "$PWD" --ticket "${directory##*/}") + else + runner=(python3 "$resolver" --root . resolve --ticket-dir "$directory") + fi + if "${runner[@]}" "${arguments[@]}" >/dev/null; then + status=0 + else + status=$? + fi + case "$status" in + 0) return 0 ;; + 1) return 1 ;; + *) exit 1 ;; + esac +} + +# The dimension vocabularies live in the work classification contract, which is +# shipped to targets as .governance/ and kept at governance/ in the hub. Reading +# them keeps this script from drifting away from the contract it must satisfy. +classification_dsl() { + local candidate + for candidate in .governance/work-classification.dsl.json governance/work-classification.dsl.json; do + if [[ -f "$candidate" ]]; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + +require_classification_value() { + local dimension="$1" value="$2" dsl + if ! dsl="$(classification_dsl)"; then + echo "GOV-CLASS-000: work classification contract not found; cannot validate --$dimension." >&2 + echo " remediation: restore .governance/work-classification.dsl.json from the pinned package." >&2 + exit 1 + fi + local allowed + allowed="$(python3 -c 'import json,sys +data = json.load(open(sys.argv[1])) +print("\n".join(data["dimensions"][sys.argv[2]]))' "$dsl" "$dimension")" + if ! grep -Fxq -- "$value" <<< "$allowed"; then + echo "GOV-CLASS-001: '$value' is not a declared $dimension in $dsl." >&2 + echo " accepted: $(printf '%s' "$allowed" | tr '\n' ' ')" >&2 + exit 1 + fi +} + +require_classification_value kind "$KIND" +require_classification_value priority "$PRIORITY" +require_classification_value origin "$ORIGIN" + +# The incoming immutable package owns its verifier; the plan never names code. +# Old adopters invoke this published source package's native allocator from +# their primary checkout. A partial installed package is not a fallback. +verify_standard_adoption() { + local runtime="$ALLOCATOR_PACKAGE_ROOT/scripts/create_adoption_lock.py" + if [[ ! -f "$runtime" || -L "$runtime" ]]; then + echo "GOV-TICKET-ALLOCATION-003: adoption allocation requires the complete immutable source package." >&2 + return 5 + fi + python3 -B - "$runtime" "$STANDARD_ADOPTION_PLAN" "$PWD" "$WORKSTREAM" "${SCOPE_ARGUMENTS[@]}" <<'PYADOPTION' +import importlib.util, json, sys +from pathlib import Path +runtime, plan_path, root, workstream, *scope = sys.argv[1:] +spec = importlib.util.spec_from_file_location("native_adoption_plan", runtime) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +plan = module.verify_allocation_plan(Path(plan_path), Path(root), workstream, + [argument.removeprefix("--path=") for argument in scope] or None) +print(json.dumps({key: plan[key] for key in ("allowedPaths", "delivery", "digest")})) +PYADOPTION +} + +# Validate the explicit scope before any identity reservation or registered +# allocation request. The same argv is used for admission and both stores. +if [[ -n "$STANDARD_ADOPTION_PLAN" ]]; then + if ! ADOPTION_PROJECTION="$(verify_standard_adoption)"; then + echo "GOV-TICKET-ALLOCATION-003: adoption allocation proof is invalid; preserve the target and recompute the exact published plan." >&2 + exit 5 + fi + mapfile -t adoption_paths < <(python3 -B -c 'import json,sys; print("\n".join(json.load(sys.stdin)["allowedPaths"]))' <<< "$ADOPTION_PROJECTION") + SCOPE_ARGUMENTS=() + for adoption_path in "${adoption_paths[@]}"; do SCOPE_ARGUMENTS+=("--path=$adoption_path"); done +elif (( ${#SCOPE_ARGUMENTS[@]} )); then + if [[ -z "$TICKET_STORAGE_HELPER" ]]; then + echo "GOV-WORK-START-001: explicit scope requires the managed ticket storage bridge." >&2 + exit 3 + fi + scope_bootstrap_arguments=() + if [[ -n "$BOOTSTRAP_ADOPTION_DIGEST" ]]; then + scope_bootstrap_arguments+=(--bootstrap-adoption-digest "$BOOTSTRAP_ADOPTION_DIGEST") + fi + python3 "$TICKET_STORAGE_HELPER" scope --root "$PWD" --workstream "$WORKSTREAM" \ + "${scope_bootstrap_arguments[@]}" "${SCOPE_ARGUMENTS[@]}" >/dev/null +fi + +allocation_config() { + local candidate + for candidate in .governance/ticket-allocation.json governance/ticket-allocation.json; do + if [[ -f "$candidate" ]]; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + +allocation_runtime() { + local candidate + if [[ -n "$SNAPSHOT_PROPOSAL" || -n "$SNAPSHOT_MATERIALIZE" || -n "$MIGRATION_AUTHORIZATION" || -n "$MIGRATION_AUTHORIZATION_SHA256" ]]; then + for candidate in "$ALLOCATOR_PACKAGE_ROOT/scripts/ticket_allocation.py" "$ALLOCATOR_PACKAGE_ROOT/.governance/ticket_allocation.py"; do + if [[ -f "$candidate" ]]; then printf '%s' "$candidate"; return 0; fi + done + return 1 + fi + for candidate in .governance/ticket_allocation.py scripts/ticket_allocation.py; do + if [[ -f "$candidate" ]]; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + +ALLOCATION_MODE="local-single-clone" +ALLOCATION_CONFIG="" +ALLOCATION_RUNTIME="" +if ALLOCATION_CONFIG="$(allocation_config)"; then + if ! ALLOCATION_RUNTIME="$(allocation_runtime)"; then + echo "GOV-TICKET-ALLOCATION-003: ticket allocation policy exists but its managed validator is missing." >&2 + echo " remediation: restore the complete pinned governance package before allocating." >&2 + exit 5 + fi + if ! ALLOCATION_MODE="$(python3 "$ALLOCATION_RUNTIME" mode --config "$ALLOCATION_CONFIG")"; then + echo " remediation: restore a valid managed ticket-allocation/v1 policy." >&2 + exit 5 + fi +fi +if [[ "$ALLOCATION_MODE" == "local-single-clone" && ( -n "$ALLOCATION_KEY" || -n "$ALLOCATION_RECEIPT" ) ]]; then + echo "GOV-TICKET-ALLOCATION-003: registered allocation inputs are forbidden in local-single-clone mode." >&2 + echo " remediation: remove the inputs or adopt a registered allocator policy." >&2 + exit 5 +fi + +if [[ -n "$SEED_BASELINE_REQUEST" ]]; then + if [[ "$ALLOCATION_MODE" != local-single-clone || "$TICKET_STORAGE" != files \ + || -z "$PRIMARY_CHECKOUT" || "$WRITER_IDENTITY_EXPLICIT" != true \ + || -n "$RECOVER_REQUEST" || -n "$STANDARD_ADOPTION_PLAN" \ + || "$REFRESH_REMOTE" == true || "$FORCE_NEW" == true ]]; then + echo "GOV-TICKET-ALLOCATION-003: seed requires explicit local file allocation and writer identity only." >&2 + exit 5 + fi + SEED_RUNNER="$PRIMARY_CHECKOUT/.governance/seed_baseline.py" + python3 -B "$SEED_RUNNER" preflight --root "$PRIMARY_CHECKOUT" \ + --request "$SEED_BASELINE_REQUEST" --actor "$WRITER_ACTOR" --session "$WRITER_SESSION" + SEED_MODE=true +fi +if [[ -n "$STANDARD_ADOPTION_PLAN" && "$ALLOCATION_MODE" != local-single-clone ]]; then + echo "GOV-TICKET-ALLOCATION-003: adoption plans do not support registered allocation." >&2 + exit 5 +fi + +if [[ -n "$BOOTSTRAP_ADOPTION_DIGEST" ]]; then + if [[ ! "$BOOTSTRAP_ADOPTION_DIGEST" =~ ^[0-9a-f]{64}$ || "$TICKET_STORAGE" != files || "$ALLOCATION_MODE" != local-single-clone || "$REFRESH_REMOTE" == true || "$FORCE_NEW" == true || ${#SCOPE_ARGUMENTS[@]} -eq 0 || -n "$RECOVER_REQUEST$SNAPSHOT_PROPOSAL$SNAPSHOT_MATERIALIZE$MIGRATION_AUTHORIZATION$MIGRATION_AUTHORIZATION_SHA256" ]]; then + echo "GOV-WORK-START-001: bootstrap acknowledgement requires exclusive first bounded file allocation." >&2 + exit 3 + fi + # Keep the validated bridge for metadata rendering after entering the new + # checkout; its base deliberately does not yet contain native governance. + TICKET_STORAGE_HELPER="$(cd "$(dirname "$TICKET_STORAGE_HELPER")" && pwd -P)/$(basename "$TICKET_STORAGE_HELPER")" +fi + +# Serialize allocation across every worktree sharing this clone. The high-water +# mark reserves a number even before its ticket is committed and therefore +# remains visible when another worktree cannot see the new directory. +if [[ -n "$SNAPSHOT_PROPOSAL" || -n "$SNAPSHOT_MATERIALIZE" || -n "$MIGRATION_AUTHORIZATION" || -n "$MIGRATION_AUTHORIZATION_SHA256" ]]; then + if [[ "$TICKET_STORAGE" != files || "$ALLOCATION_MODE" != local-single-clone || "$REFRESH_REMOTE" == true || "$FORCE_NEW" == true || ${#SCOPE_ARGUMENTS[@]} -ne 0 || -n "$RECOVER_REQUEST" || ( -n "$SNAPSHOT_PROPOSAL" && -n "$SNAPSHOT_MATERIALIZE" ) ]]; then + echo "GOV-TICKET-ALLOCATION-003: snapshot inputs require local file allocation and an exclusive bounded request." >&2 + exit 5 + fi + snapshot_arguments=(--root "$CALLER_CHECKOUT" --workstream "$WORKSTREAM") + if [[ -n "$SNAPSHOT_PROPOSAL" ]]; then + if [[ -n "$RECOVERY_LEASE_STORE" || -n "$MIGRATION_AUTHORIZATION" || -n "$MIGRATION_AUTHORIZATION_SHA256" ]]; then + echo "GOV-TICKET-ALLOCATION-003: proposal reservation has no authority inputs." >&2 + exit 5 + fi + snapshot_arguments+=(--prepare "$SNAPSHOT_PROPOSAL") + elif [[ -n "$SNAPSHOT_MATERIALIZE" && -n "$RECOVERY_LEASE_STORE" && -n "$MIGRATION_AUTHORIZATION" && -n "$MIGRATION_AUTHORIZATION_SHA256" ]]; then + snapshot_arguments+=(--materialize "$SNAPSHOT_MATERIALIZE" --lease-store "$RECOVERY_LEASE_STORE" --authorization "$MIGRATION_AUTHORIZATION" --authorization-sha256 "$MIGRATION_AUTHORIZATION_SHA256") + else + echo "GOV-TICKET-ALLOCATION-003: incomplete protected materialization inputs." >&2 + exit 5 + fi + cd "$CALLER_CHECKOUT" + for candidate in "$ALLOCATOR_PACKAGE_ROOT/scripts/snapshot_allocation.py" "$ALLOCATOR_PACKAGE_ROOT/.governance/snapshot_allocation.py"; do + if [[ -f "$candidate" ]]; then + exec python3 "$candidate" "${snapshot_arguments[@]}" + fi + done + echo "GOV-TICKET-ALLOCATION-003: restore the managed snapshot allocator." >&2 + exit 5 +fi + +if [[ -n "$RECOVER_REQUEST" || -n "$RECOVERY_LEASE_STORE" ]]; then + if [[ -z "$RECOVER_REQUEST" || -z "$RECOVERY_LEASE_STORE" || "$TICKET_STORAGE" != files || "$ALLOCATION_MODE" != local-single-clone || "$REFRESH_REMOTE" == true || "$FORCE_NEW" == true || ${#SCOPE_ARGUMENTS[@]} -ne 0 ]]; then + echo "GOV-TICKET-ALLOCATION-003: recovery requires both inputs, file storage and local allocation; scope comes only from the bound request." >&2 + exit 5 + fi + # Recovery is deliberately different from normal allocation: its request + # binds an already-existing canonical linked checkout. Resolve normal + # allocation from primary, but execute this bounded recovery at the caller + # checkout so its exact ticket branch and managed helper remain available. + # Restoration re-registers an already reserved identity whose checkout is + # lost; it shares these inputs and exclusions but never reserves a number. + cd "$CALLER_CHECKOUT" + for candidate in ".governance/$RECOVERY_HELPER.py" "scripts/$RECOVERY_HELPER.py"; do + if [[ -f "$candidate" ]]; then + exec python3 "$candidate" --root . --request "$RECOVER_REQUEST" --lease-store "$RECOVERY_LEASE_STORE" --workstream "$WORKSTREAM" + fi + done + echo "GOV-TICKET-ALLOCATION-003: restore the managed recovery helper." >&2 + exit 5 +fi + +# Fail before clone locking, identity reservation or worktree creation when +# the host cannot provide race-resistant directory-relative lease writes. +if [[ "$TICKET_STORAGE" != sqlite ]]; then + python3 - <<'PY_LEASE_CAPABILITIES' +import os +if (not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY") + or os.open not in os.supports_dir_fd or os.mkdir not in os.supports_dir_fd): + raise SystemExit("GOV-TICKET-ALLOCATION-003: race-resistant lease creation is unsupported on this host") +PY_LEASE_CAPABILITIES +fi + +allocation_lock="" +allocation_state="" +release_allocation_lock() { + if [[ -n "$allocation_lock" ]]; then + rmdir "$allocation_lock" 2>/dev/null || true + fi +} +if git_common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)"; then + allocation_lock="$git_common_dir/new-project-ticket-allocation.lock" + allocation_state="$git_common_dir/new-project-ticket-high-water" + if ! mkdir "$allocation_lock" 2>/dev/null; then + echo "GOV-TICKET-LOCK-001: another ticket allocation is active in this clone." >&2 + echo " remediation: wait for it to finish; remove a stale lock only after confirming no allocator is running." >&2 + exit 4 + fi + trap release_allocation_lock EXIT + trap 'exit 130' INT + trap 'exit 143' TERM +fi + +# Remote refresh is explicit. The start check below uses only observed refs. +if [[ "$REFRESH_REMOTE" == true ]] \ + && git rev-parse --git-dir >/dev/null 2>&1 \ + && git remote get-url origin >/dev/null 2>&1; then + if ! git fetch --prune origin '+refs/heads/*:refs/remotes/origin/*' >/dev/null 2>&1; then + echo "GOV-TICKET-LOCK-004: remote ticket refs could not be refreshed safely." >&2 + echo " remediation: restore origin connectivity and retry, or omit --refresh-remote and rely on local refs plus protected merge collision detection." >&2 + exit 4 + fi +fi + +# Before reserving an ID or contacting the registered allocator, inspect all +# registered worktrees and local branches, not just this checkout's ticket. +# The clone allocation lock covers this observation and the identity effect; +# it is NOT a writer lease. Recheck admission and fencing before development. +# Unborn/non-Git bootstrap has no branch history yet and retains seed behavior. +if [[ -n "$STANDARD_ADOPTION_PLAN" ]]; then + if ! rechecked_projection="$(verify_standard_adoption)"; then + echo "GOV-TICKET-ALLOCATION-003: adoption allocation proof changed under the clone lock." >&2 + exit 5 + fi + if [[ "$rechecked_projection" != "$ADOPTION_PROJECTION" ]]; then + echo "GOV-TICKET-ALLOCATION-003: adoption plan changed before reservation." >&2 + exit 5 + fi +fi +if git rev-parse --verify HEAD >/dev/null 2>&1; then + start_runtime="" + for candidate in .governance/work_start_check.py scripts/work_start_check.py; do + if [[ -f "$candidate" ]]; then + start_runtime="$candidate" + break + fi + done + if [[ -z "$start_runtime" ]]; then + echo "GOV-WORK-START-001: managed work-start checker is missing; restore the complete pinned package." >&2 + exit 3 + fi + bootstrap_arguments=() + if [[ -n "$BOOTSTRAP_ADOPTION_DIGEST" ]]; then + bootstrap_arguments+=(--bootstrap-adoption-digest "$BOOTSTRAP_ADOPTION_DIGEST") + fi + if ! start_report="$(python3 "$start_runtime" --root . --workstream "$WORKSTREAM" --storage "$TICKET_STORAGE" "${SCOPE_ARGUMENTS[@]}" "${bootstrap_arguments[@]}" --allocation-check)"; then + printf '%s\n' "$start_report" >&2 + echo "GOV-WORK-START-001: reuse, assist, hand off or serialize existing work before new allocation; preserve all checkouts." >&2 + exit 3 + fi +fi + +# A ticket number taken on a branch is invisible on disk in another worktree. +# Consult every local and fetched remote branch known to this clone. +refs_highest() { + local highest_ref=0 ref number decimal + while read -r ref; do + [[ -n "$ref" ]] || continue + while read -r number; do + decimal=$((10#$number)) + (( decimal > highest_ref )) && highest_ref=$decimal + done < <( + git ls-tree -d -r --name-only "$ref" -- project 2>/dev/null \ + | sed -nE 's|^project/ticket-([0-9]+)$|\1|p' + ) + done < <(git for-each-ref --format='%(refname)' refs/heads refs/remotes 2>/dev/null) + printf '%s' "$highest_ref" +} + +highest=0 +conflicting_ticket="" +current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" +if [[ "$current_branch" =~ ticket[-/]([0-9]{3,}) ]]; then + current_ticket="project/ticket-${BASH_REMATCH[1]}" + if is_active_ticket "$current_ticket"; then + conflicting_ticket="$current_ticket" + fi +fi +if git rev-parse --git-dir >/dev/null 2>&1; then + highest="$(refs_highest)" + if [[ -n "$TICKET_STORAGE_HELPER" ]]; then + database_highest="$(python3 "$TICKET_STORAGE_HELPER" highest --root "$PWD")" + (( database_highest > highest )) && highest=$database_highest + fi + if [[ -n "$allocation_state" && -f "$allocation_state" ]]; then + read -r reserved_highest < "$allocation_state" + if [[ ! "$reserved_highest" =~ ^[0-9]+$ ]]; then + echo "GOV-TICKET-LOCK-002: ticket allocation state is invalid." >&2 + exit 4 + fi + reserved_decimal=$((10#$reserved_highest)) + (( reserved_decimal > highest )) && highest=$reserved_decimal + fi +fi +if [[ -d project ]]; then + for dir in project/ticket-*; do + [[ -d "$dir" ]] || continue + number="${dir##*-}" + [[ "$number" =~ ^[0-9]+$ ]] || continue + decimal=$((10#$number)) + (( decimal > highest )) && highest=$decimal + if [[ -z "$current_branch" ]] && is_active_ticket "$dir"; then + active_workstream="$(sed -nE 's/^[[:space:]]*"workstream"[[:space:]]*:[[:space:]]*"([a-z0-9-]+)".*/\1/p' "$dir/intent.json" 2>/dev/null | head -n 1)" + if [[ -z "$active_workstream" || "$active_workstream" == "unresolved" || "$WORKSTREAM" == "unresolved" || "$active_workstream" == "$WORKSTREAM" ]]; then + conflicting_ticket="$dir" + fi + fi + done +fi + +if [[ -n "$conflicting_ticket" && "$FORCE_NEW" != true ]]; then + echo "Active ticket conflicts with workstream '$WORKSTREAM': $conflicting_ticket" >&2 + echo "Continue it, or return to the default branch before allocating a distinct workstream." >&2 + exit 3 +fi + +if [[ "$ALLOCATION_MODE" == "registered" ]]; then + if [[ -z "$ALLOCATION_KEY" ]]; then + echo "GOV-TICKET-ALLOCATION-003: registered mode requires --allocation-key from the Supervisor correlation." >&2 + echo " remediation: retry with the stable task correlation; never invent a local sequence." >&2 + exit 5 + fi + if ! origin_url="$(git config --get remote.origin.url 2>/dev/null)"; then + echo "GOV-TICKET-ALLOCATION-003: registered mode requires a canonical origin repository." >&2 + exit 5 + fi + if ! repository_ref="$(python3 "$ALLOCATION_RUNTIME" repository-ref --url "$origin_url")"; then + exit 5 + fi + allocation_arguments=( + --config "$ALLOCATION_CONFIG" + --repository-ref "$repository_ref" + --allocation-key "$ALLOCATION_KEY" + --title "$TITLE" + --agent "$AGENT" + --workstream "$WORKSTREAM" + --kind "$KIND" + --priority "$PRIORITY" + --origin "$ORIGIN" + ) + if [[ -z "$ALLOCATION_RECEIPT" ]]; then + echo "GOV-TICKET-ALLOCATION-003: registered allocation receipt is required; submit this request to the configured process URI." >&2 + python3 "$ALLOCATION_RUNTIME" request "${allocation_arguments[@]}" + exit 5 + fi + if ! ticket_num="$(python3 "$ALLOCATION_RUNTIME" validate "${allocation_arguments[@]}" --receipt "$ALLOCATION_RECEIPT")"; then + echo " remediation: obtain a fresh receipt from the configured process URI for this exact request." >&2 + exit 5 + fi + next_num=$((10#$ticket_num)) + if (( next_num <= highest )); then + echo "GOV-TICKET-ALLOCATION-004: registered ticket $ticket_num is already visible in repository state." >&2 + echo " remediation: continue the existing claim or request a fresh fenced allocation; do not recreate or rename it." >&2 + exit 5 + fi +else + next_num=$((highest + 1)) + ticket_num="$(printf '%03d' "$next_num")" +fi +ticket_num="$(printf '%03d' "$next_num")" +ticket_id="ticket-$ticket_num" +ticket_dir="project/$ticket_id" +timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +date_only="${timestamp%%T*}" + +reserve_ticket_number() { + [[ -n "$allocation_state" ]] || return 0 + allocation_state_tmp="$allocation_state.$$" + printf '%s\n' "$next_num" > "$allocation_state_tmp" + mv "$allocation_state_tmp" "$allocation_state" +} + +if [[ "$TICKET_STORAGE" == sqlite ]]; then + # Retain the existing private clone counter for older allocators. Ticket + # contents and revisions are stored only in SQLite, never in this cache. + reserve_ticket_number + python3 "$TICKET_STORAGE_HELPER" create --root "$PWD" --ticket "$ticket_id" \ + --title "$TITLE" --workstream "$WORKSTREAM" --kind "$KIND" --priority "$PRIORITY" --origin "$ORIGIN" \ + --allocation-key "${ALLOCATION_KEY:-local:$ticket_id}" \ + --runtime-root "$STORE_ROOT" --runtime-sha256 "$STORE_SHA256" "${SCOPE_ARGUMENTS[@]}" + exit 0 +fi + +WORKTREE_PATH="" +LEASE_PATH="" +if [[ -n "$PRIMARY_CHECKOUT" && "$SEED_MODE" != true ]]; then + # Filesystem-backed ticket content must never be materialized in the primary + # checkout. Plan and verify the exact v5 path before reserving identity; + # after reservation every later failure remains observable for recovery. + if [[ -z "$WORKTREE_SLUG" ]]; then + WORKTREE_SLUG="$(printf '%s' "$TITLE" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + fi + if [[ ! "$WORKTREE_SLUG" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "GOV-TICKET-ALLOCATION-003: --worktree-slug must contain lowercase ASCII words separated by hyphens." >&2 + exit 5 + fi + + WORKTREE_CONTRACT="" + for candidate in .governance/worktree_path_check.py subprojects/worktrees/conformance.py; do + if [[ -f "$candidate" ]]; then + WORKTREE_CONTRACT="$candidate" + break + fi + done + if [[ -z "$WORKTREE_CONTRACT" ]]; then + echo "GOV-TICKET-ALLOCATION-003: Worktrees v5 conformance checker is missing." >&2 + exit 5 + fi + if ! probe="$(python3 "$WORKTREE_CONTRACT" feature-probe --from-worktree "$PRIMARY_CHECKOUT")"; then + echo "GOV-TICKET-ALLOCATION-003: unable to probe Git Worktrees v5 support." >&2 + exit 5 + fi + if ! python3 -c 'import json,sys; raise SystemExit(0 if json.load(sys.stdin).get("supported") is True else 1)' <<< "$probe"; then + echo "GOV-TICKET-ALLOCATION-003: Git 2.51+ with relative worktree add and repair support is required." >&2 + exit 5 + fi + # The mandatory work-start check above rejects overlapping dirty changes + # and reservations. With an explicit admitted scope, unrelated primary + # changes are preserved: the new worktree starts from the committed base. + # Without a bounded scope, retain the conservative dirty-primary refusal. + if (( ${#SCOPE_ARGUMENTS[@]} == 0 )) \ + && [[ -n "$(git -C "$PRIMARY_CHECKOUT" status --porcelain=v1 --untracked-files=all)" ]]; then + echo "GOV-TICKET-ALLOCATION-003: dirty primary requires an explicit disjoint --path scope admitted by work-start; preserve existing changes." >&2 + exit 5 + fi + observe_primary_base() { + local branch head target remote + branch="$(git -C "$PRIMARY_CHECKOUT" symbolic-ref --quiet HEAD 2>/dev/null)" || return 1 + head="$(git -C "$PRIMARY_CHECKOUT" rev-parse --verify 'HEAD^{commit}')" || return 1 + target="$(git -C "$PRIMARY_CHECKOUT" rev-parse --verify "refs/heads/$TARGET_BRANCH^{commit}")" || return 1 + remote="$(git -C "$PRIMARY_CHECKOUT" rev-parse --verify "refs/remotes/origin/$TARGET_BRANCH^{commit}" 2>/dev/null || true)" + [[ "$head" == "$target" ]] || return 1 + if [[ "$branch" != "refs/heads/$TARGET_BRANCH" ]]; then + # A named alias is safe only with a complete, identical observed target. + # This does not refresh refs, switch primary or release active scope. + [[ "$branch" == refs/heads/* && "$remote" == "$head" ]] || return 1 + fi + printf '%s\t%s\t%s\t%s\n' "$branch" "$head" "$target" "$remote" + } + if ! primary_base_observation="$(observe_primary_base)"; then + echo "GOV-TICKET-ALLOCATION-003: primary must be on $TARGET_BRANCH or a named alias exactly matching local and origin target commits." >&2 + exit 5 + fi + base_sha="$(cut -f2 <<< "$primary_base_observation")" + # The layout contract needs a stable label, not a remote URL (which may + # contain credentials in misconfigured clones). + repository_ref="$(basename "$PRIMARY_CHECKOUT")" + if ! layout="$(python3 "$WORKTREE_CONTRACT" plan --repository "$repository_ref" \ + --repository-name "$(basename "$PRIMARY_CHECKOUT")" --ticket "$ticket_id" \ + --slug "$WORKTREE_SLUG" --from-worktree "$PRIMARY_CHECKOUT" \ + --path-style "$(python3 -c 'import os; print("windows" if os.name == "nt" else "posix")')")"; then + echo "GOV-TICKET-ALLOCATION-003: canonical Worktrees v5 layout could not be planned." >&2 + exit 5 + fi + if ! python3 "$WORKTREE_CONTRACT" validate - --check-filesystem <<< "$layout" >/dev/null; then + echo "GOV-TICKET-ALLOCATION-003: canonical worktree or lease path is unsafe." >&2 + exit 5 + fi + mapfile -t layout_values < <(python3 -c ' +import json, sys +value = json.load(sys.stdin) +for key in ("branch", "worktreePath", "leasePath"): + # Bash mapfile requires LF delimiters even with native Windows Python. + sys.stdout.buffer.write((value[key] + "\n").encode("utf-8")) +' <<< "$layout") + WORKTREE_BRANCH="${layout_values[0]:-}" + WORKTREE_PATH="${layout_values[1]:-}" + LEASE_PATH="${layout_values[2]:-}" + branch_exists=false + if git -C "$PRIMARY_CHECKOUT" show-ref --verify --quiet "refs/heads/$WORKTREE_BRANCH"; then + branch_exists=true + fi + if [[ -z "$WORKTREE_BRANCH" || -z "$WORKTREE_PATH" || -z "$LEASE_PATH" \ + || -e "$WORKTREE_PATH" || -e "$LEASE_PATH" \ + || "$branch_exists" == true ]]; then + echo "GOV-TICKET-ALLOCATION-004: canonical ticket identity is already present or invalid." >&2 + exit 5 + fi + + if [[ -n "$STANDARD_ADOPTION_PLAN" && "$(verify_standard_adoption)" != "$ADOPTION_PROJECTION" ]]; then + echo "GOV-TICKET-ALLOCATION-003: adoption plan changed at the identity boundary." >&2 + exit 5 + fi + if ! rechecked_primary_base="$(observe_primary_base)" \ + || [[ "$rechecked_primary_base" != "$primary_base_observation" ]]; then + echo "GOV-TICKET-ALLOCATION-003: primary branch or target changed before ticket reservation." >&2 + exit 5 + fi + reserve_ticket_number + if ! git -C "$PRIMARY_CHECKOUT" worktree add --relative-paths -b "$WORKTREE_BRANCH" "$WORKTREE_PATH" "$base_sha"; then + echo "GOV-TICKET-ALLOCATION-004: ticket number remains reserved; preserve and reconcile the failed worktree allocation." >&2 + exit 5 + fi + cd "$WORKTREE_PATH" + ticket_dir="project/$ticket_id" +fi + +if [[ -n "$BOOTSTRAP_ADOPTION_DIGEST" ]]; then + mkdir -p project +fi + +if ! mkdir "$ticket_dir" 2>/dev/null; then + echo "GOV-TICKET-LOCK-003: ticket directory already exists or cannot be created: $ticket_dir" >&2 + exit 4 +fi +reserve_ticket_number + +escape_sed() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//&/\\&}" + value="${value//|/\\|}" + printf '%s' "$value" +} + +render_template() { + local source="$1" + local target="$2" + sed \ + -e "s|{TICKET_ID}|$(escape_sed "$ticket_id")|g" \ + -e "s|{NNN}|$(escape_sed "$ticket_num")|g" \ + -e "s|{SHORT_TITLE}|$(escape_sed "$TITLE")|g" \ + -e "s|{TIMESTAMP}|$(escape_sed "$timestamp")|g" \ + -e "s|{YYYY-MM-DD}|$(escape_sed "$date_only")|g" \ + -e "s|{OWNER_NAME}|unresolved:human|g" \ + -e "s|{PROVIDER}|$(escape_sed "$AGENT")|g" \ + -e "s|{WORKSTREAM}|$(escape_sed "$WORKSTREAM")|g" \ + "$source" > "$target" +} + +json_escape() { + # JSON escaping is syntax, not title interpretation. ASCII output preserves + # Unicode scalars without depending on the host's stdout encoding. + python3 -c 'import json, sys; print(json.dumps(sys.argv[1])[1:-1], end="")' "$1" +} + +render_json_template() { + local source="$1" + local target="$2" + sed \ + -e "s|{TICKET_ID}|$(escape_sed "$(json_escape "$ticket_id")")|g" \ + -e "s|{NNN}|$(escape_sed "$(json_escape "$ticket_num")")|g" \ + -e "s|{SHORT_TITLE}|$(escape_sed "$(json_escape "$TITLE")")|g" \ + -e "s|{TIMESTAMP}|$(escape_sed "$(json_escape "$timestamp")")|g" \ + -e "s|{YYYY-MM-DD}|$(escape_sed "$(json_escape "$date_only")")|g" \ + -e "s|{PROVIDER}|$(escape_sed "$(json_escape "$AGENT")")|g" \ + -e "s|{WORKSTREAM}|$(escape_sed "$(json_escape "$WORKSTREAM")")|g" \ + -e "s|{KIND}|$(escape_sed "$(json_escape "$KIND")")|g" \ + -e "s|{PRIORITY}|$(escape_sed "$(json_escape "$PRIORITY")")|g" \ + -e "s|{ORIGIN}|$(escape_sed "$(json_escape "$ORIGIN")")|g" \ + "$source" > "$target" +} + +if [[ -f template/files/ticket.template.md ]]; then + render_template template/files/ticket.template.md "$ticket_dir/README.md" +else + cat > "$ticket_dir/README.md" < "$ticket_dir/intent.json" </dev/null + fi +fi + +if [[ "$TICKET_STORAGE" != "sqlite" ]]; then + # Assess the actual declared scope before writer-reservation hashes are made. + # The external product is advisory; its absence is recorded in the README. + # Helpers older than the radar subcommand must degrade to the same advisory + # marker instead of failing the allocation (forward compatibility). + if [[ -n "$TICKET_STORAGE_HELPER" ]]; then + if ! python3 -B "$TICKET_STORAGE_HELPER" radar --root "$PWD" --ticket "$ticket_id" 2>/dev/null; then + echo "ticket-radar advisory: storage helper declined the assessment; README records it." >&2 + cat >> "$ticket_dir/README.md" <<'RADAR' + + +## Ticket Radar + +Assessment unavailable: managed ticket storage bridge declined the radar assessment. +Advisory only; no execution or merge approval. + +RADAR + fi + else + cat >> "$ticket_dir/README.md" <<'RADAR' + + +## Ticket Radar + +Assessment unavailable: managed ticket storage bridge is not installed. +Advisory only; no execution or merge approval. + +RADAR + fi +fi + +if [[ "$SEED_MODE" == true ]]; then + # Only the seed's own ticket carriers may precede a real baseline. Ordinary + # implementation still receives a canonical linked checkout and lease. + python3 -B "$SEED_RUNNER" prepare --root "$PRIMARY_CHECKOUT" \ + --request "$SEED_BASELINE_REQUEST" --actor "$WRITER_ACTOR" \ + --session "$WRITER_SESSION" --ticket "$ticket_id" + python3 -B "$SEED_RUNNER" commit --root "$PRIMARY_CHECKOUT" \ + --request "$SEED_BASELINE_REQUEST" --actor "$WRITER_ACTOR" --session "$WRITER_SESSION" + base_sha="$(git rev-parse HEAD)" + if [[ -z "$WORKTREE_SLUG" ]]; then + WORKTREE_SLUG="$(printf '%s' "$TITLE" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + fi + repository_ref="$(basename "$PRIMARY_CHECKOUT")" + layout="$(python3 .governance/worktree_path_check.py plan --repository "$repository_ref" \ + --repository-name "$repository_ref" --ticket "$ticket_id" --slug "$WORKTREE_SLUG" \ + --from-worktree "$PRIMARY_CHECKOUT" --path-style posix)" + python3 .governance/worktree_path_check.py validate - --check-filesystem <<< "$layout" >/dev/null + mapfile -t layout_values < <(python3 -c 'import json,sys; v=json.load(sys.stdin); print(v["branch"]); print(v["worktreePath"]); print(v["leasePath"])' <<< "$layout") + WORKTREE_BRANCH="${layout_values[0]}" + WORKTREE_PATH="${layout_values[1]}" + LEASE_PATH="${layout_values[2]}" + git worktree add --relative-paths -b "$WORKTREE_BRANCH" "$WORKTREE_PATH" "$base_sha" + cd "$WORKTREE_PATH" +fi + +if [[ -n "$LEASE_PATH" ]]; then + # A lease is local operational state, deliberately ignored by Git. Its + # identity and scope bind the new branch/worktree before any implementation + # begins; O_EXCL means a stale or competing lease is never overwritten. + python3 - "$ticket_dir/intent.json" "$LEASE_PATH" "$ticket_id" "$WORKSTREAM" \ + "$WORKTREE_BRANCH" "$WORKTREE_SLUG" "$repository_ref" "$TARGET_BRANCH" \ + "$base_sha" "$WRITER_ACTOR" "$WRITER_SESSION" "$timestamp" <<'PY' +from datetime import datetime, timedelta, timezone +import hashlib +import json +import os +from pathlib import Path +import sys + +(intent_path, lease_path, ticket, workstream, branch, slug, repository, + target_branch, head, actor, session, issued_at) = sys.argv[1:] +intent_file = Path(intent_path) +lease_file = Path(lease_path) +if intent_file.is_symlink() or lease_file.exists() or lease_file.is_symlink(): + raise SystemExit("ticket intent or lease identity is unsafe") +intent = json.loads(intent_file.read_text(encoding="utf-8")) +allowed = intent.get("allowedPaths") +if not isinstance(allowed, list) or not all(isinstance(path, str) and path for path in allowed): + raise SystemExit("allocated intent scope is invalid") +canonical = lambda value: json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") +issued = datetime.fromisoformat(issued_at.replace("Z", "+00:00")) +lease = { + "schema": "wellmanifest.change-lease/v1", + "leaseId": f"allocation-{ticket}-{slug}", + # This ignored allocator projection is advisory local state, not a + # controller grant or a claim about a remote organization. The layout + # label may be a basename; scope that case explicitly to this primary. + "repositoryRef": repository if "/" in repository else "local/" + hashlib.sha256( + str(lease_file.parent.parent.parent.resolve()).encode("utf-8")).hexdigest(), + "targetBranch": target_branch, + "ticketId": ticket, + "workstream": workstream, + "scopeHash": hashlib.sha256(canonical(allowed)).hexdigest(), + "branchRef": "refs/heads/" + branch, + "worktreeId": f"{ticket}--{slug}", + "ownerActor": actor, + "ownerSession": session, + "phase": "claimed", + "leaseRevision": 1, + "fencingToken": 1, + "issuedAt": issued_at, + "expiresAt": (issued + timedelta(hours=2)).isoformat().replace("+00:00", "Z"), + "heartbeatAt": issued_at, + "headSha": head, + "pullRequest": None, + "validatorRunId": None, + "publicationFrozen": False, + "planHash": hashlib.sha256(canonical(intent)).hexdigest(), + "previousReceiptRef": None, + "eventSequence": 1, +} +# Bind every directory lookup to an already-open directory descriptor. A +# pathname check followed by pathname creation can follow a replaced ancestor. +if (not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY") + or os.open not in os.supports_dir_fd or os.mkdir not in os.supports_dir_fd): + raise SystemExit("race-resistant lease creation is unsupported on this host") +if not lease_file.is_absolute() or ".." in lease_file.parts: + raise SystemExit("lease path must be an absolute canonical path") +directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW +parent_descriptor = os.open(lease_file.anchor, directory_flags) +try: + for component in lease_file.parts[1:-1]: + try: + os.mkdir(component, mode=0o700, dir_fd=parent_descriptor) + except FileExistsError: + pass + child_descriptor = os.open(component, directory_flags, dir_fd=parent_descriptor) + os.close(parent_descriptor) + parent_descriptor = child_descriptor + payload = json.dumps(lease, indent=2, sort_keys=True).encode("utf-8") + b"\n" + descriptor = os.open(lease_file.name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=parent_descriptor) + with os.fdopen(descriptor, "wb") as stream: + stream.write(payload) +finally: + os.close(parent_descriptor) +PY +fi + +if [[ -n "$USERS" ]]; then + echo "warning: --users=$USERS did not create user-* files; human-owned input must come from a human or trusted intake boundary" >&2 +fi + +# The older adopter's index generator may require a different carrier format. +# A plan allocates untracked metadata only; leave existing index bytes intact +# until the separately authorized material upgrade installs its pinned tool. +if [[ -z "$STANDARD_ADOPTION_PLAN" && -f project/readme.sh ]]; then + T2C_TICKET_INDEX_OWN="project/$ticket_id" bash ./project/readme.sh +fi + +if [[ -n "$WORKTREE_PATH" ]]; then + echo "Successfully allocated $ticket_id for '$TITLE' in $WORKTREE_PATH." +else + echo "Successfully scaffolded $ticket_dir for '$TITLE'." +fi +# Additive result: consumers select the returned identity, never a directory +# glob or the last ticket number. Emit only after every allocation step passes. +# The allocator's ignored lease projection does not grant controller authority. +python3 - "$ticket_id" "${WORKTREE_BRANCH:-}" "$WORKTREE_PATH" "$LEASE_PATH" <<'PY' +import json +import sys + +ticket, branch, worktree, lease = sys.argv[1:] +print(json.dumps({ + "schema": "new-project.ticket-allocation-result/v1", + "ticket": ticket, + "branch": branch or None, + "worktreePath": worktree or None, + "leasePath": lease or None, + "storage": "files", + "kind": "delivery" if worktree else "scaffold-only", + "executionAuthorized": False, +}, ensure_ascii=True, sort_keys=True)) +PY diff --git a/project/readme.sh b/project/readme.sh new file mode 100755 index 0000000..f7ac993 --- /dev/null +++ b/project/readme.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Universal ticket index generator for target System X repositories. + +set -euo pipefail + +index_file="${T2C_TICKET_INDEX_FILE:-project/TICKETS.md}" +case "$index_file" in + project/*) ;; + *) + echo "Ticket index must stay under project/: $index_file" >&2 + exit 2 + ;; +esac + +if [[ "$index_file" == *".."* ]]; then + echo "Ticket index cannot contain parent traversal: $index_file" >&2 + exit 2 +fi + +# The allocator names the ticket it has just created in this checkout. That one +# untracked directory is indexed so the ticket's own PR carries exactly its own +# row, instead of the next ticket's PR adding it (issue #516). +own_ticket="${T2C_TICKET_INDEX_OWN:-}" +if [[ -n "$own_ticket" && ! "$own_ticket" =~ ^project/ticket-[0-9]+$ ]]; then + echo "Own ticket must be project/ticket-NNN: $own_ticket" >&2 + exit 2 +fi + +mkdir -p project +if [[ ! -f "$index_file" ]]; then + if [[ -f template/files/project.template.md ]]; then + timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + sed "s/{TIMESTAMP}/$timestamp/g" template/files/project.template.md > "$index_file" + else + cat > "$index_file" <<'EOF' +# Ticket index (`project/`) + +This file indexes governance tickets without taking ownership of +`project/README.md`, which may belong to an analysis generator. + + + +EOF + fi +fi + +start_line="$(grep -n '^$' "$index_file" | cut -d: -f1 || true)" +end_line="$(grep -n '^$' "$index_file" | cut -d: -f1 || true)" +if [[ -z "$start_line" && -z "$end_line" ]]; then + printf '\n\n\n' >> "$index_file" + start_line="$(grep -n '^$' "$index_file" | cut -d: -f1 || true)" + end_line="$(grep -n '^$' "$index_file" | cut -d: -f1 || true)" +fi +start_count="$(echo -n "$start_line" | grep -c . || true)" +end_count="$(echo -n "$end_line" | grep -c . || true)" +if [[ "$start_count" != 1 || "$end_count" != 1 ]]; then + echo "$index_file must contain exactly one ticket-index marker pair" >&2 + exit 2 +fi +if [[ "$start_line" -ge "$end_line" ]]; then + echo "$index_file ticket-index markers must appear in order (START before END)" >&2 + exit 2 +fi + +table_file="$(mktemp "${TMPDIR:-/tmp}/new-project-ticket-table.XXXXXX")" +index_dir="$(dirname "$index_file")" +output_file="$(mktemp "$index_dir/.ticket-index.XXXXXX")" +cleanup() { + rm -f "$table_file" "$output_file" +} +trap cleanup EXIT INT TERM + +printf '%s\n' \ + '| Ticket ID | Spec | Preprompt | Human input | Agent plans | Agent logs | Changelog |' \ + '| :--- | :--- | :--- | :--- | :--- | :--- | :--- |' > "$table_file" + +for dir in project/ticket-*; do + [[ -d "$dir" ]] || continue + # A ticket git does not track yet belongs to work in flight, often somebody + # else's. Indexing it writes rows whose links resolve in no commit but that + # author's working tree, so whoever regenerates the index next ships broken + # links. An untracked ticket appears in the index once it is committed. + if [[ "$dir" != "$own_ticket" ]] && git rev-parse --git-dir >/dev/null 2>&1 && [[ -z "$(git ls-files -- "$dir")" ]]; then + printf 'skipping untracked %s; commit it to have it indexed\n' "$dir" >&2 + continue + fi + ticket_name="$(basename "$dir")" + spec='-' + preprompt='-' + changelog='-' + [[ -f "$dir/README.md" ]] && spec="[\`README.md\`](./$ticket_name/README.md)" + [[ -f "$dir/preprompt.md" ]] && preprompt="[\`preprompt.md\`](./$ticket_name/preprompt.md)" + [[ -f "$dir/changelog.md" ]] && changelog="[\`changelog.md\`](./$ticket_name/changelog.md)" + + humans="" + for file in "$dir"/user-*.md; do + [[ -f "$file" ]] || continue + name="$(basename "$file")" + humans+=" [\`$name\`](./$ticket_name/$name)" + done + [[ -n "$humans" ]] || humans='-' + + agents="" + for file in "$dir"/ai-*.md; do + [[ -f "$file" ]] || continue + name="$(basename "$file")" + agents+=" [\`$name\`](./$ticket_name/$name)" + done + [[ -n "$agents" ]] || agents='-' + + logs="" + for file in "$dir"/ai-*-logs.txt; do + [[ -f "$file" ]] || continue + name="$(basename "$file")" + logs+=" [\`$name\`](./$ticket_name/$name)" + done + [[ -n "$logs" ]] || logs='-' + + printf '| **%s** | %s | %s | %s | %s | %s | %s |\n' \ + "$ticket_name" "$spec" "$preprompt" "$humans" "$agents" "$logs" "$changelog" >> "$table_file" +done + +awk -v table="$table_file" ' + /^$/ { + print + while ((getline line < table) > 0) print line + close(table) + inside = 1 + next + } + /^$/ { + inside = 0 + print + next + } + !inside { print } +' "$index_file" > "$output_file" + +chmod 0644 "$output_file" +mv "$output_file" "$index_file" +echo "Updated $index_file ticket index successfully." diff --git a/project/ticket-001/README.md b/project/ticket-001/README.md new file mode 100644 index 0000000..1b512ab --- /dev/null +++ b/project/ticket-001/README.md @@ -0,0 +1,41 @@ +# Ticket 001: Adopt immutable native governance for campaign admission + +- **ID**: ticket-001 +- **Owner**: agent:codex +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-10-31 + +## Goal and scope + +SESSION_EXECUTION_AUTHORIZATION: user requested executing, merging, testing and repairing the fleet recovery decisions on 2026-10-31. Install the published native governance 0.20.129 at 82fd58dbe175f90146ed49c07fa86fac91de00d5 to replace the incomplete registration scaffold. Preserve original local files in an external digest-bound secret-scanned snapshot. The allocator owns this canonical checkout and ticket identity; only the actual controller lease authorizes implementation. + +The wheel must include the governance pytest bridge so the installed pytest +console entry point can import it independently of the working directory. + +## Acceptance criteria + +- [x] AC-01: Complete pinned governance package and prerequisite files are present. +- [x] AC-02: Product tests and governance checks pass. +- [ ] AC-03: Independent exact-head approval and protected merge are observed. + +## Published129 continuation + +SESSION_EXECUTION_AUTHORIZATION persists from the user request to execute, test, +repair and merge. Refresh the existing119 adoption candidate to independently +published129. Preserve the old signed review and all its29 unresolved findings; +new source, tests and runtime qualification require fresh independent review. +Use the approved fullWellman d8c7a0a runtime, checkout-local .venv and private +execution scratch. Product code, dependencies and unknown primary scaffolding +remain outside this continuation. The three new managed paths are taken only +from the pinned Goal preview. + +## Validation of published 0.20.129 + +Native Goal reports the package up to date at the exact published revision. +The managed governance gate passes with zero errors and warnings; all two +existing product tests pass. Both Python executions have canonical full Wellman +receipts bound to this checkout and its local environment. The pyproject change +is limited to the standard version and source revision; dependency declarations +and product source are unchanged. OneDev runtime qualification and independent +current-head review remain pending. diff --git a/project/ticket-001/intent.json b/project/ticket-001/intent.json new file mode 100644 index 0000000..9dd112b --- /dev/null +++ b/project/ticket-001/intent.json @@ -0,0 +1,366 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-001", + "summary": "Adopt immutable native governance for campaign admission", + "workstream": "governance", + "classification": { + "kind": "SERVICE", + "priority": "P2", + "origin": "requested" + }, + "allowedPaths": [ + ".aider.conf.yml", + ".cursor/rules/new-project-standard.mdc", + ".gitattributes", + ".githooks/commit-msg", + ".githooks/pre-commit", + ".githooks/pre-push", + ".github/copilot-instructions.md", + ".github/workflows/new-project-branch-hygiene.yml", + ".github/workflows/new-project-governance.yml", + ".gitignore", + ".governance/AGENT_DECISIONS.md", + ".governance/adoption-bindings.json", + ".governance/adoption-bindings.schema.json", + ".governance/agent-hosts.json", + ".governance/agent-hosts.schema.json", + ".governance/agent_host_check.py", + ".governance/approval-evidence.schema.json", + ".governance/branch-intent-reconciliation.schema.json", + ".governance/branch_intent_reconciliation.py", + ".governance/branch_lifecycle_check.py", + ".governance/change-evaluation.schema.json", + ".governance/change-lease.schema.json", + ".governance/change_lease_check.py", + ".governance/check_required_checks.py", + ".governance/decision-record.schema.json", + ".governance/decision_record.py", + ".governance/diagnostics.json", + ".governance/diagnostics.schema.json", + ".governance/docs/BRANCH_INTENT_RECONCILIATION.md", + ".governance/docs/LOCAL_CI_PUBLICATION.md", + ".governance/docs/SNAPSHOT_MIGRATION.md", + ".governance/error/GOV-AGENT-HOST.md", + ".governance/error/GOV-APPROVAL.md", + ".governance/error/GOV-ARCHITECTURE-001.md", + ".governance/error/GOV-CHANGE-LEASE.md", + ".governance/error/GOV-HOST-MERGE.md", + ".governance/error/GOV-INTENT.md", + ".governance/error/GOV-PACKAGING.md", + ".governance/error/GOV-PHYS.md", + ".governance/error/GOV-PUSH.md", + ".governance/error/GOV-REMEDIATION-INTENT.md", + ".governance/error/GOV-SNAPSHOT-MIGRATION.md", + ".governance/error/GOV-STANDARD-UPDATE.md", + ".governance/error/GOV-TICKET-001.md", + ".governance/error/GOV-TICKET-ACTIVITY.md", + ".governance/error/GOV-TICKET-ALLOCATION.md", + ".governance/error/GOV-VERSION.md", + ".governance/error/GOV-WORK-CONTINUITY.md", + ".governance/error/GOV-WORK-START.md", + ".governance/error/GOV-WORKSPACE-LIFECYCLE.md", + ".governance/error/GOV-WORKTREE-OVERLAP.md", + ".governance/error/README.md", + ".governance/generate_required_checks.py", + ".governance/governance_check.py", + ".governance/intent.schema.json", + ".governance/lock.schema.json", + ".governance/managed-copies.json", + ".governance/manifest.base.json", + ".governance/manifest.json", + ".governance/manifest.lock.json", + ".governance/manifest.schema.json", + ".governance/package-manifest.json", + ".governance/physical-contracts.schema.json", + ".governance/policy-dsl.lock.json", + ".governance/policy_dsl_check.py", + ".governance/precommit_standard_update.py", + ".governance/prune_merged_worktrees.py", + ".governance/remediation-intent.schema.json", + ".governance/remediation-intent.template.dsl.json", + ".governance/remediation_intent.py", + ".governance/repository_policy.py", + ".governance/required-checks.json", + ".governance/required-checks.schema.json", + ".governance/reusable-workflows.schema.json", + ".governance/seed_baseline.py", + ".governance/snapshot-migration.schema.json", + ".governance/snapshot_allocation.py", + ".governance/snapshot_migration.py", + ".governance/stack-profiles.json", + ".governance/standard-adoption-plan.schema.json", + ".governance/standard-adoption.json", + ".governance/standard-adoption.schema.json", + ".governance/standard-packs.json", + ".governance/standard-propagation-request.schema.json", + ".governance/standard_pack_check.py", + ".governance/templates/conftest-worktree-bootstrap.py", + ".governance/terminal-receipt-registry.schema.json", + ".governance/ticket-activity-override.schema.json", + ".governance/ticket-activity.json", + ".governance/ticket-activity.schema.json", + ".governance/ticket-allocation-receipt.schema.json", + ".governance/ticket-allocation-request.schema.json", + ".governance/ticket-allocation.json", + ".governance/ticket-allocation.schema.json", + ".governance/ticket_activity.py", + ".governance/ticket_allocation.py", + ".governance/ticket_index_merge_driver.py", + ".governance/ticket_input.py", + ".governance/ticket_recovery.py", + ".governance/ticket_restoration.py", + ".governance/ticket_storage.py", + ".governance/work-classification.dsl.json", + ".governance/work-classification.schema.json", + ".governance/work-continuity.schema.json", + ".governance/work-start-report.schema.json", + ".governance/work_continuity.py", + ".governance/work_start_check.py", + ".governance/workspace_lifecycle_check.py", + ".governance/worktree_guard.py", + ".governance/worktree_overlap_check.py", + ".governance/worktree_path_check.py", + ".governance/worktrees.lock.json", + ".governance/worktrees.schema.json", + ".subactor/.gitignore", + ".subactor/manifest.json", + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "GEMINI.md", + "README.md", + "TODO.md", + "VERSION", + "project.bat", + "project.sh", + "project/TICKETS.md", + "project/governance-check.bat", + "project/governance-check.sh", + "project/new-ticket.sh", + "project/readme.sh", + "project/ticket-001/**", + "pyproject.toml", + "scripts/install-agent-hosts.sh", + "scripts/runtime.sh", + "wellmanifest_governance.py", + "worktree-guard.yaml", + ".governance/error/GOV-IDLE-TAKEOVER.md", + ".governance/idle_takeover.py", + ".governance/terminal_lease_release.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "ca230d2cc2b311020769b55b005c568ccb195d55", + "targetBranch": "main", + "outcome": "Complete the original initial adoption with independently published native governance 0.20.131, verify unchanged product behavior through checkout-bound Wellman execution, separately qualify the protected OneDev runtime, then obtain fresh exact-head independent approval and protected merge.", + "nonGoals": [ + "Change product runtime or dependencies", + "Discard unknown local work", + "Waive protected checks", + "Do not erase, reset or waive the original signed119 review findings.", + "Do not use the editable global wellman wrapper, another project environment or unmediated candidate Python/pytest." + ], + "complexity": "L", + "estimatedMinutes": 90, + "budgets": { + "maxImplementationFiles": 15, + "maxAffectedComponents": 5, + "maxPublicInterfaceChanges": 3, + "maxRuntimeDependencies": 3 + }, + "standardAdoption": { + "sourceRepository": "wellmanifest/new-project", + "fromRevision": null, + "toRevision": "3926cecf965cd03a39cc992aa40bf82ae2e97a13" + }, + "architecture": { + "status": "accepted", + "decision": "Keep the accepted original main base and initial-adoption fromRevision=null: this PR is still the first adoption relative to main. Replace only the immutable managed closure from the local119 candidate with published129 through native Goal, including the three exact newly published managed targets. Preserve target overrides and product source/dependencies. Change only standard/revision in pyproject tool.wellmanifest to match the package through its closed native packaging binding. Use the independently approved fullWellman d8c7a0a runtime, local checkout .venv and private execution scratch/receipts; immutable operator tools and control-plane receipts have distinct declared roles. The original29-finding signed119 review remains intact; source fixes and local PASS never constitute approval. Independently qualify exact129 package and manifest pins in OneDev before trusting its current-head result.", + "components": [ + { + "name": "governance", + "paths": [ + ".aider.conf.yml", + ".cursor/rules/new-project-standard.mdc", + ".gitattributes", + ".githooks/commit-msg", + ".githooks/pre-commit", + ".githooks/pre-push", + ".github/copilot-instructions.md", + ".github/workflows/new-project-branch-hygiene.yml", + ".github/workflows/new-project-governance.yml", + ".gitignore", + ".governance/AGENT_DECISIONS.md", + ".governance/adoption-bindings.json", + ".governance/adoption-bindings.schema.json", + ".governance/agent-hosts.json", + ".governance/agent-hosts.schema.json", + ".governance/agent_host_check.py", + ".governance/approval-evidence.schema.json", + ".governance/branch-intent-reconciliation.schema.json", + ".governance/branch_intent_reconciliation.py", + ".governance/branch_lifecycle_check.py", + ".governance/change-evaluation.schema.json", + ".governance/change-lease.schema.json", + ".governance/change_lease_check.py", + ".governance/check_required_checks.py", + ".governance/decision-record.schema.json", + ".governance/decision_record.py", + ".governance/diagnostics.json", + ".governance/diagnostics.schema.json", + ".governance/docs/BRANCH_INTENT_RECONCILIATION.md", + ".governance/docs/LOCAL_CI_PUBLICATION.md", + ".governance/docs/SNAPSHOT_MIGRATION.md", + ".governance/error/GOV-AGENT-HOST.md", + ".governance/error/GOV-APPROVAL.md", + ".governance/error/GOV-ARCHITECTURE-001.md", + ".governance/error/GOV-CHANGE-LEASE.md", + ".governance/error/GOV-HOST-MERGE.md", + ".governance/error/GOV-INTENT.md", + ".governance/error/GOV-PACKAGING.md", + ".governance/error/GOV-PHYS.md", + ".governance/error/GOV-PUSH.md", + ".governance/error/GOV-REMEDIATION-INTENT.md", + ".governance/error/GOV-SNAPSHOT-MIGRATION.md", + ".governance/error/GOV-STANDARD-UPDATE.md", + ".governance/error/GOV-TICKET-001.md", + ".governance/error/GOV-TICKET-ACTIVITY.md", + ".governance/error/GOV-TICKET-ALLOCATION.md", + ".governance/error/GOV-VERSION.md", + ".governance/error/GOV-WORK-CONTINUITY.md", + ".governance/error/GOV-WORK-START.md", + ".governance/error/GOV-WORKSPACE-LIFECYCLE.md", + ".governance/error/GOV-WORKTREE-OVERLAP.md", + ".governance/error/README.md", + ".governance/generate_required_checks.py", + ".governance/governance_check.py", + ".governance/intent.schema.json", + ".governance/lock.schema.json", + ".governance/managed-copies.json", + ".governance/manifest.base.json", + ".governance/manifest.json", + ".governance/manifest.lock.json", + ".governance/manifest.schema.json", + ".governance/package-manifest.json", + ".governance/physical-contracts.schema.json", + ".governance/policy-dsl.lock.json", + ".governance/policy_dsl_check.py", + ".governance/precommit_standard_update.py", + ".governance/prune_merged_worktrees.py", + ".governance/remediation-intent.schema.json", + ".governance/remediation-intent.template.dsl.json", + ".governance/remediation_intent.py", + ".governance/repository_policy.py", + ".governance/required-checks.json", + ".governance/required-checks.schema.json", + ".governance/reusable-workflows.schema.json", + ".governance/seed_baseline.py", + ".governance/snapshot-migration.schema.json", + ".governance/snapshot_allocation.py", + ".governance/snapshot_migration.py", + ".governance/stack-profiles.json", + ".governance/standard-adoption-plan.schema.json", + ".governance/standard-adoption.json", + ".governance/standard-adoption.schema.json", + ".governance/standard-packs.json", + ".governance/standard-propagation-request.schema.json", + ".governance/standard_pack_check.py", + ".governance/templates/conftest-worktree-bootstrap.py", + ".governance/terminal-receipt-registry.schema.json", + ".governance/ticket-activity-override.schema.json", + ".governance/ticket-activity.json", + ".governance/ticket-activity.schema.json", + ".governance/ticket-allocation-receipt.schema.json", + ".governance/ticket-allocation-request.schema.json", + ".governance/ticket-allocation.json", + ".governance/ticket-allocation.schema.json", + ".governance/ticket_activity.py", + ".governance/ticket_allocation.py", + ".governance/ticket_index_merge_driver.py", + ".governance/ticket_input.py", + ".governance/ticket_recovery.py", + ".governance/ticket_restoration.py", + ".governance/ticket_storage.py", + ".governance/work-classification.dsl.json", + ".governance/work-classification.schema.json", + ".governance/work-continuity.schema.json", + ".governance/work-start-report.schema.json", + ".governance/work_continuity.py", + ".governance/work_start_check.py", + ".governance/workspace_lifecycle_check.py", + ".governance/worktree_guard.py", + ".governance/worktree_overlap_check.py", + ".governance/worktree_path_check.py", + ".governance/worktrees.lock.json", + ".governance/worktrees.schema.json", + ".subactor/.gitignore", + ".subactor/manifest.json", + "AGENTS.md", + "CHANGELOG.md", + "CLAUDE.md", + "GEMINI.md", + "README.md", + "TODO.md", + "VERSION", + "project.bat", + "project.sh", + "project/TICKETS.md", + "project/governance-check.bat", + "project/governance-check.sh", + "project/new-ticket.sh", + "project/readme.sh", + "pyproject.toml", + "scripts/install-agent-hosts.sh", + "scripts/runtime.sh", + "wellmanifest_governance.py", + "worktree-guard.yaml", + ".governance/error/GOV-IDLE-TAKEOVER.md", + ".governance/idle_takeover.py", + ".governance/terminal_lease_release.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the adoption commit; exact original partial scaffolding is retained in the external snapshot." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "goal governance adopt --source-revision 82fd58dbe175f90146ed49c07fa86fac91de00d5 --target-root . --check" + ], + "evidence": "Complete source129 managed closure, actual official publication and exact target metadata bindings; preserve product dependency declarations." + }, + { + "criterion": "AC-02", + "commands": [ + "wellman run --root . -- python -m pytest -q", + "project/governance-check.sh through the pinned canonical Wellman Python dispatch" + ], + "evidence": "Actual checkout-local interpreter/source/temp/cache binding and real command/exit receipts; product tests and deterministic managed gate pass." + }, + { + "criterion": "AC-03", + "commands": [ + "Protected OneDev exact129 runtime qualification", + "Invoke independent Validator on the frozen current PR head" + ], + "evidence": "Actual runtime deployment and exact repository/base/head/profile result, then current-head trusted approval and controller-owned merge; previous signed refusal unchanged." + } + ] + } +} diff --git a/pyproject.toml b/pyproject.toml index 09da7c5..567d1e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,17 @@ classifiers = [ ] dependencies = [] +[tool.setuptools] +py-modules = ["wellmanifest_governance"] + [tool.setuptools.packages.find] where = ["."] include = ["code2graph*"] + +[tool.wellmanifest] +standard = "0.20.131" +revision = "3926cecf965cd03a39cc992aa40bf82ae2e97a13" +gate = "project/governance-check.sh" + +[tool.pytest.ini_options] +addopts = "-p wellmanifest_governance" diff --git a/scripts/install-agent-hosts.sh b/scripts/install-agent-hosts.sh new file mode 100755 index 0000000..1e432ce --- /dev/null +++ b/scripts/install-agent-hosts.sh @@ -0,0 +1,446 @@ +#!/usr/bin/env bash +# Activate the host-agnostic new-project contract in a clone, and optionally +# bootstrap it into another checkout or into user-level LLM host directories. +# +# The file list is not hardcoded here: it is read from the agent host contract +# (governance/agent-hosts.json or .governance/agent-hosts.json) and, when +# bootstrapping a different checkout, from governance/package-manifest.json. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: ./scripts/install-agent-hosts.sh [options] + + --source DIR Hub or already-populated checkout (default: this script's repo) + --target DIR Git clone that should receive host files and hooksPath + --user Also install user-level Cursor / Gemini / Claude pointers + --check Report what is missing and exit non-zero; change nothing + -h, --help Show this help + +With no --target and no --user the current git work tree is activated in place: +host files already delivered by adoption are verified, the hook is made +executable and core.hooksPath is set. Nothing is copied over itself. +EOF +} + +SOURCE="" +TARGET="" +USER_INSTALL=false +CHECK_ONLY=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --source) + [[ $# -ge 2 && -n "${2:-}" ]] || { echo "Missing value for --source" >&2; exit 2; } + SOURCE="$2"; shift 2 ;; + --target) + [[ $# -ge 2 && -n "${2:-}" ]] || { echo "Missing value for --target" >&2; exit 2; } + TARGET="$2"; shift 2 ;; + --user) USER_INSTALL=true; shift ;; + --check) CHECK_ONLY=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -z "$SOURCE" ]]; then + SOURCE="$(cd "$script_dir/.." && pwd)" +fi +SOURCE="$(cd "$SOURCE" && pwd)" + +if [[ -z "$TARGET" && "$USER_INSTALL" == false ]]; then + if git rev-parse --show-toplevel >/dev/null 2>&1; then + TARGET="$(git rev-parse --show-toplevel)" + else + echo "No --target and current directory is not a git work tree." >&2 + usage >&2 + exit 2 + fi +fi + +contract_path() { + local root="$1" + for candidate in "governance/agent-hosts.json" ".governance/agent-hosts.json"; do + if [[ -f "$root/$candidate" ]]; then + python3 - "$root/$candidate" <<'PY' || return 1 +import json, sys +contract = json.load(open(sys.argv[1], encoding="utf-8")) +hooks = contract.get("hook", {}).get("additionalHooks", []) +allowed = {".githooks/commit-msg", ".githooks/prepare-commit-msg", ".githooks/pre-push"} +if (not isinstance(hooks, list) + or any(not isinstance(hook, str) or hook not in allowed for hook in hooks) + or len(hooks) != len(set(hooks))): + raise SystemExit("GOV-AGENT-HOST-004: invalid additional hook declaration") +PY + printf '%s\n' "$root/$candidate" + return 0 + fi + done + echo "Agent host contract not found under $root" >&2 + return 1 +} + +# Emits "\t" lines for every file the contract governs. +contract_targets() { + python3 - "$1" "$2" <<'PY' +import json, pathlib, sys +contract = json.load(open(sys.argv[1], encoding="utf-8")) +root = pathlib.Path(sys.argv[2]) +hub = root / "governance/manifest.hub.json" +source_paths = {} +if hub.is_file(): + package = json.loads((root / "governance/package-manifest.json").read_text()) + source_paths = {item["target"]: item["source"] for item in package["files"]} +for host in contract["hosts"]: + print(f"{host['file']}\t0") +print(f"{contract['hook']['path']}\t1") +for hook in contract["hook"].get("additionalHooks", []): + print(f"{hook}\t1") +for runtime_file in contract["hook"]["runtimeFiles"]: + # The hub owns package sources; adopters own the managed target paths. + relative = source_paths.get(runtime_file, runtime_file) + print(f"{relative}\t0") +PY +} + +hooks_path_config() { + python3 - "$1" <<'PY' +import json, sys +print(json.load(open(sys.argv[1], encoding="utf-8"))["hook"]["hooksPathConfig"]) +PY +} + +# Validate the complete selected package before copying anything. The caller +# owns the target checkout exclusively; observed path changes fail closed. +bootstrap_host_files() { + python3 - "$1" "$2" "$3" <<'PY' +import json +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import stat +import sys +import tempfile + + +def fail(message): + raise SystemExit("GOV-AGENT-HOST-004: " + message) + + +def alias(metadata): + return stat.S_ISLNK(metadata.st_mode) or bool(getattr(metadata, 'st_file_attributes', 0) & 0x400) + + +def relative(value): + if (not isinstance(value, str) or not value or '\\' in value or ':' in value + or any(ord(char) < 32 or ord(char) == 127 for char in value)): + fail('invalid host package path') + path = PurePosixPath(value) + if (path.is_absolute() or PureWindowsPath(value).drive or path.as_posix() != value + or any(part in {'', '.', '..'} or part.casefold() == '.git' + or part.endswith(('.', ' ')) for part in value.split('/'))): + fail('host package paths must be canonical and repository-relative') + return path.parts + + +def directory_root(value): + path = Path(value).absolute() + for item in (path, *path.parents): + metadata = item.lstat() + if alias(metadata) or not stat.S_ISDIR(metadata.st_mode): + fail('bootstrap roots must be regular directories without aliases') + return path.resolve() + + +def checked(root, name, *, required=False, directory=False): + parts = relative(name) + path = root + for index, part in enumerate(parts): + path /= part + try: + metadata = path.lstat() + except FileNotFoundError: + if required: + fail('a required host package file is missing') + continue + if alias(metadata): + fail('symlink or reparse point in host package path') + expect_directory = index < len(parts) - 1 or directory + if not (stat.S_ISDIR(metadata.st_mode) if expect_directory else stat.S_ISREG(metadata.st_mode)): + fail('unsupported host package filesystem entry') + return path + + +def identity(metadata): + return metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_size, metadata.st_mtime_ns + + +def source_bytes(root, name): + path = checked(root, name, required=True) + before = path.lstat() + flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0) | getattr(os, 'O_BINARY', 0) + descriptor = os.open(path, flags) + with os.fdopen(descriptor, 'rb') as stream: + opened = os.fstat(stream.fileno()) + if alias(opened) or not stat.S_ISREG(opened.st_mode) or identity(opened) != identity(before): + fail('host package source changed before reading') + data = stream.read() + after = os.fstat(stream.fileno()) + observed = checked(root, name, required=True).lstat() + if identity(before) != identity(after) or identity(before) != identity(observed): + fail('host package source changed while reading') + return data + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + fail('duplicate host package JSON key') + result[key] = value + return result + + +def document(data): + value = json.loads(data.decode('utf-8'), object_pairs_hook=unique_object) + if not isinstance(value, dict): + fail('host package metadata must be an object') + return value + + +try: + source, target = directory_root(sys.argv[1]), directory_root(sys.argv[2]) + contract_names = ('governance/agent-hosts.json', '.governance/agent-hosts.json') + contract_source = next((name for name in contract_names if (source / name).exists() or (source / name).is_symlink()), None) + if contract_source is None: + fail('source host contract is missing') + frozen = {contract_source: source_bytes(source, contract_source), + 'governance/package-manifest.json': source_bytes(source, 'governance/package-manifest.json')} + contract = document(frozen[contract_source]) + manifest = document(frozen['governance/package-manifest.json']) + hook = contract['hook'] + if not isinstance(hook, dict): + fail('host hook declaration must be an object') + hosts = [host['file'] for host in contract['hosts']] + additional = hook.get('additionalHooks', []) + allowed = {'.githooks/commit-msg', '.githooks/prepare-commit-msg', '.githooks/pre-push'} + if not isinstance(additional, list) or len(set(additional)) != len(additional) or any(name not in allowed for name in additional): + fail('invalid additional hook declaration') + governed = set(hosts + [hook['path']] + additional + hook['runtimeFiles']) + for name in governed: + relative(name) + relative(hook['hooksPathConfig']) + if PurePosixPath(hook['path']).parent.as_posix() != hook['hooksPathConfig']: + fail('hook path and hooksPathConfig disagree') + selected, targets, plan, contracts = [], set(), [], [] + if not isinstance(manifest['files'], list): + fail('host package mappings must be an array') + for item in manifest['files']: + if not isinstance(item, dict): + fail('host package mappings must be objects') + if item['target'] in governed or item['source'] == contract_source: + relative(item['source']) + relative(item['target']) + folded = item['target'].casefold() + if folded in targets or not isinstance(item['executable'], bool): + fail('duplicate target or invalid executable flag') + targets.add(folded) + checked(target, item['target']) + if item['source'] not in frozen: + frozen[item['source']] = source_bytes(source, item['source']) + data = frozen[item['source']] + plan.append((item['target'], data, item['executable'])) + selected.append(item['target']) + if item['source'] == contract_source: + contracts.append(item['target']) + if governed - set(selected) or len(contracts) != 1 or contracts[0] not in contract_names: + fail('incomplete or ambiguous host package mapping') + # Activation prefers governance/ over .governance/. Preserve an unrelated + # existing contract rather than copying files and activating another one. + for name in contract_names: + if name in selected: + break + if (target / name).exists() or (target / name).is_symlink(): + if document(source_bytes(target, name)) != contract: + fail('existing target host contract differs from the selected package') + break + if sys.argv[3] != 'true': + for name, data, executable in plan: + parts = relative(name) + for index in range(1, len(parts)): + parent_name = '/'.join(parts[:index]) + parent = checked(target, parent_name, directory=True) + parent.mkdir(exist_ok=True) + checked(target, parent_name, required=True, directory=True) + destination = checked(target, name) + descriptor, temporary = tempfile.mkstemp(prefix='.host-bootstrap.', dir=destination.parent) + try: + with os.fdopen(descriptor, 'wb') as stream: + stream.write(data) + os.chmod(temporary, 0o755 if executable else 0o644) + checked(target, name) + os.replace(temporary, destination) + finally: + if os.path.exists(temporary): + os.unlink(temporary) +except (OSError, ValueError, TypeError, KeyError): + fail('host package is invalid, unreadable or changed during bootstrap') +PY +} + +activate_in_place() { + local dest="$1" + dest="$(cd "$dest" && pwd)" + local contract hooks targets + contract="$(contract_path "$dest")" || return 1 + hooks="$(hooks_path_config "$contract")" || return 1 + targets="$(contract_targets "$contract" "$dest")" || return 1 + local missing=() + + while IFS=$'\t' read -r target is_hook; do + if [[ ! -f "$dest/$target" ]]; then + missing+=("$target") + continue + fi + done <<< "$targets" + + if [[ "${#missing[@]}" -gt 0 ]]; then + printf 'GOV-AGENT-HOST-004: missing host files in %s:\n' "$dest" >&2 + printf ' %s\n' "${missing[@]}" >&2 + echo " Adopt the current standard package, or bootstrap with --source --target $dest" >&2 + return 1 + fi + + if [[ "$CHECK_ONLY" == true ]]; then + while IFS=$'\t' read -r target is_hook; do + if [[ "$is_hook" == "1" && ! -x "$dest/$target" ]]; then + echo "GOV-AGENT-HOST-005: hook is not executable: $dest/$target" >&2 + return 1 + fi + done <<< "$targets" + local configured; configured="$(git -C "$dest" config --get core.hooksPath || true)" + if [[ "$configured" != "$hooks" ]]; then + echo "GOV-AGENT-HOST-006: core.hooksPath is '${configured:-unset}', expected '$hooks'" >&2 + return 1 + fi + echo "Host contract is active in $dest" + return 0 + fi + + while IFS=$'\t' read -r target is_hook; do + if [[ "$is_hook" == "1" ]]; then + chmod +x "$dest/$target" || return 1 + fi + done <<< "$targets" + git -C "$dest" config core.hooksPath "$hooks" || return 1 + + local driver_path="" + for candidate in ".governance/ticket_index_merge_driver.py" "scripts/ticket_index_merge_driver.py"; do + if [[ -f "$dest/$candidate" ]]; then + driver_path="$candidate" + break + fi + done + if [[ -n "$driver_path" ]]; then + git -C "$dest" config merge.wellmanifest-ticket-index.name "Wellmanifest Ticket Index Merge Driver" || true + git -C "$dest" config merge.wellmanifest-ticket-index.driver "python3 $driver_path %O %A %B %P" || true + fi + + echo "Activated host contract and core.hooksPath=$hooks in $dest" +} + +bootstrap_into() { + local dest="$1" + dest="$(cd "$dest" && pwd)" + if ! git -C "$dest" rev-parse --show-toplevel >/dev/null 2>&1; then + echo "Target is not a git work tree: $dest" >&2 + exit 1 + fi + bootstrap_host_files "$SOURCE" "$dest" "$CHECK_ONLY" || return 1 + activate_in_place "$dest" +} + +install_user_files() { + local task_user_home="${HOME:-}" + if [[ -z "$task_user_home" || ! -d "$task_user_home" ]]; then + echo "HOME is not a directory; skipping --user" >&2 + return 1 + fi + if [[ "$CHECK_ONLY" == true ]]; then + echo "--check does not inspect user-level pointers" + return 0 + fi + local rule="$SOURCE/.cursor/rules/new-project-standard.mdc" + [[ -f "$rule" ]] || { echo "Source file missing: $rule" >&2; return 1; } + + mkdir -p "$task_user_home/.cursor/rules" "$task_user_home/.gemini" "$task_user_home/.claude" "$task_user_home/.config/aider" || return 1 + cp -f "$rule" "$task_user_home/.cursor/rules/new-project-standard.mdc" || return 1 + + local marker="wellmanifest/new-project host contract" + for pointer in "$task_user_home/.gemini/GEMINI.md" "$task_user_home/.claude/CLAUDE.md"; do + # Governance can exist only in a registered ticket worktree until its + # adoption merges, so discovery must not depend on the current checkout. + # Replace only the managed paragraph after the marker; keep other text. + python3 - "$pointer" "$marker" <<'PY' || return 1 +import os, pathlib, sys, tempfile +path, marker = pathlib.Path(sys.argv[1]), sys.argv[2] +paragraph = [ + "When the current Git checkout, or any checkout listed by `git worktree list`,", + "has `./project/new-ticket.sh`, or its primary checkout has", + "`.subactor/leases/*.json`, follow the host contract and `AGENTS.md` of the", + "ticket checkout that owns the work, even if the current checkout lacks them.", + "Allocate tickets only through that script. Never commit on main or a dirty", + "primary checkout. Run `./scripts/install-agent-hosts.sh` once per clone so", + "the git hook is active.", + "Put every final result in the owning repository through its ticket:", + "reports and analyses under `docs/` per `wellmanifest/docs` (`check.py --prepare`", + "before writing, `--deliverable` after commit), error runbooks under `errors/`", + "per `wellmanifest/logs`. Never leave a final result only in /tmp, a scratchpad,", + "`$HOME/.local/state` or the chat. Before claiming done run", + "`./project/governance-check.sh` and the Wellman standards check (`wellman check`);", + "a required standard that is not installed is an error to fix, not to skip.", +] +text = path.read_text(encoding="utf-8") if path.exists() else "" +lines = text.splitlines() +heading = f"# {marker}" +if lines.count(heading) > 1: + raise SystemExit("Duplicate managed host contract heading; preserve the file and repair its boundary.") +if heading in lines: + start = lines.index(heading) + body = start + 1 + while body < len(lines) and not lines[body].strip(): + body += 1 + if body >= len(lines) or not lines[body].startswith("When the current "): + raise SystemExit("Malformed managed host contract paragraph; preserve the file and repair its boundary.") + if body < len(lines) and lines[body].startswith("When the current "): + end = body + while end < len(lines) and lines[end].strip() and not lines[end].startswith("#"): + end += 1 + lines[start:end] = [heading, "", *paragraph] + updated = "\n".join(lines) + "\n" +else: + updated = text + ("" if not text or text.endswith("\n") else "\n") + "\n" + "\n".join([heading, "", *paragraph]) + "\n" +if updated != text: + fd, temporary = tempfile.mkstemp(prefix=".host-pointer.", dir=path.parent) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(updated) + os.replace(temporary, path) +PY + done + echo "Installed user-level host pointers under $task_user_home/.cursor $task_user_home/.gemini $task_user_home/.claude" +} + +status=0 +if [[ -n "$TARGET" ]]; then + target_abs="$(cd "$TARGET" && pwd)" + if [[ "$target_abs" == "$SOURCE" ]]; then + activate_in_place "$target_abs" || status=1 + else + bootstrap_into "$target_abs" || status=1 + fi +fi +if [[ "$USER_INSTALL" == true ]]; then + install_user_files || status=1 +fi +exit "$status" diff --git a/scripts/runtime.sh b/scripts/runtime.sh new file mode 100755 index 0000000..1866dde --- /dev/null +++ b/scripts/runtime.sh @@ -0,0 +1,862 @@ +#!/usr/bin/env bash +set -euo pipefail + +runtime_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v node >/dev/null 2>&1; then + echo "EVD-RUNTIME-001: Node.js 20 or newer is required" >&2 + exit 2 +fi + +node_major="$(node -p 'Number(process.versions.node.split(".")[0])')" +if [[ ! "$node_major" =~ ^[0-9]+$ ]] || (( node_major < 20 )); then + echo "EVD-RUNTIME-001: Node.js 20 or newer is required" >&2 + exit 2 +fi + +# The body is valid TypeScript and executable JavaScript. Keeping it inside the +# Bash entrypoint avoids a transpiler/runtime dependency while preserving one +# portable file for adopted TypeScript repositories. +exec node --input-type=commonjs - "$runtime_root" "$@" <<'TYPESCRIPT' +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const argv = process.argv.slice(2); +const packagedRoot = path.resolve(argv.shift() || "."); +const command = argv.shift() || "help"; + +function usage(message) { + if (message) process.stderr.write(`EVD-RUNTIME-002: ${message}\n`); + process.stderr.write( + "Usage:\n" + + " bash scripts/runtime.sh policy [--policy CONTRIBUTING.md]\n" + + " bash scripts/runtime.sh validate --evaluation FILE --intent FILE " + + "--manifest-lock FILE [--policy FILE] [--repository-root DIR] " + + "[--json-out FILE] [--markdown-out FILE]\n", + ); + process.exit(message ? 2 : 0); +} + +function parseOptions(items) { + const options = new Map(); + for (let index = 0; index < items.length; index += 1) { + const key = items[index]; + if (!key.startsWith("--")) usage(`unexpected argument ${key}`); + if (options.has(key)) usage(`option ${key} was repeated`); + const value = items[index + 1]; + if (value === undefined || value.startsWith("--")) usage(`option ${key} requires a value`); + options.set(key, value); + index += 1; + } + return options; +} + +function sortDeep(value) { + if (Array.isArray(value)) return value.map(sortDeep); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortDeep(value[key])]), + ); + } + return value; +} + +function canonical(value) { + return JSON.stringify(sortDeep(value)); +} + +function sha256Bytes(value) { + return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; +} + +function sha256File(filePath) { + return sha256Bytes(fs.readFileSync(filePath)); +} + +function readText(filePath, label) { + try { + return fs.readFileSync(filePath, "utf8"); + } catch (error) { + throw new Error(`${label} is unreadable: ${error.message}`); + } +} + +function readJson(filePath, label) { + try { + return JSON.parse(readText(filePath, label)); + } catch (error) { + if (error.message.startsWith(`${label} is unreadable:`)) throw error; + throw new Error(`${label} is not valid JSON: ${error.message}`); + } +} + +function diagnostic(code, message, evidence, remediation) { + return { + code, + severity: "BLOCKING", + message, + evidence: Array.isArray(evidence) ? evidence : [evidence].filter(Boolean), + remediation: Array.isArray(remediation) ? remediation : [remediation].filter(Boolean), + }; +} + +const requiredEvaluationRules = Array.from( + { length: 10 }, + (_, index) => `C-EVALUATION-${String(index + 1).padStart(3, "0")}`, +); + +function validatePolicyText(policyText) { + const diagnostics = []; + const counts = new Map(); + for (const match of policyText.matchAll(/^\s*RULE\s+(C-EVALUATION-\d{3})\b/gm)) { + counts.set(match[1], (counts.get(match[1]) || 0) + 1); + } + for (const rule of requiredEvaluationRules) { + if (counts.get(rule) !== 1) { + diagnostics.push( + diagnostic( + "EVD-POLICY-001", + `${rule} must occur exactly once in the policy`, + `observed=${counts.get(rule) || 0}`, + `restore the canonical ${rule} block from wellmanifest/new-project`, + ), + ); + } + } + const requiredClauses = [ + "CHANGE_EVALUATION_SCHEMA = \"t2c.change-evaluation/v1\"", + "PUBLICATION_MODE = PULL_REQUEST_REQUIRED_FOR_IMPLEMENTATION", + "DIRECT_PUSH = FORBIDDEN_FOR_IMPLEMENTATION", + "FORBID COMPENSATE_REQUIRED_GATE_WITH_NUMERIC_SCORE", + "FORBID LLM_OUTPUT_AS_TRUSTED_APPROVAL", + ]; + for (const clause of requiredClauses) { + if (!policyText.includes(clause)) { + diagnostics.push( + diagnostic( + "EVD-POLICY-002", + `required policy clause is missing: ${clause}`, + "CONTRIBUTING.md", + "restore the canonical CHANGE EVALUATION contract", + ), + ); + } + } + return diagnostics; +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isSha(value) { + return typeof value === "string" && /^[0-9a-f]{40}$/.test(value); +} + +function isDigest(value) { + return typeof value === "string" && /^sha256:[0-9a-f]{64}$/.test(value); +} + +function validateMinimumShape(evaluation) { + const diagnostics = []; + const requiredObjects = [ + "subject", + "contract", + "changeSet", + "gates", + "dimensions", + "approval", + "contribution", + "verdict", + "confidence", + "provenance", + ]; + if (!isObject(evaluation) || evaluation.schemaVersion !== "t2c.change-evaluation/v1") { + diagnostics.push( + diagnostic( + "EVD-SCHEMA-001", + "schemaVersion must equal t2c.change-evaluation/v1", + "change-evaluation.json", + "generate the report with the published v1 schema", + ), + ); + return diagnostics; + } + for (const key of requiredObjects) { + if (!isObject(evaluation[key])) { + diagnostics.push( + diagnostic("EVD-SCHEMA-001", `${key} must be an object`, key, "provide the required v1 object"), + ); + } + } + for (const key of ["actors", "criteriaEvaluation", "findings"]) { + if (!Array.isArray(evaluation[key])) { + diagnostics.push( + diagnostic("EVD-SCHEMA-001", `${key} must be an array`, key, "provide the required v1 array"), + ); + } + } + const allowedTopLevel = new Set([ + "schemaVersion", + "subject", + "contract", + "actors", + "changeSet", + "criteriaEvaluation", + "gates", + "dimensions", + "approval", + "findings", + "contribution", + "verdict", + "confidence", + "provenance", + ]); + for (const key of Object.keys(evaluation)) { + if (!allowedTopLevel.has(key)) { + diagnostics.push( + diagnostic( + "EVD-SCHEMA-001", + `unsupported top-level property: ${key}`, + key, + "remove the property or publish a new schema version", + ), + ); + } + } + if (isObject(evaluation.subject)) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(evaluation.subject.repository || "")) { + diagnostics.push(diagnostic("EVD-SCHEMA-001", "subject.repository is invalid", "subject.repository", "use owner/repository")); + } + if (!["commit", "push", "pull_request", "merge_group"].includes(evaluation.subject.event)) { + diagnostics.push(diagnostic("EVD-SCHEMA-001", "subject.event is invalid", "subject.event", "use a v1 event")); + } + if ( + ["pull_request", "merge_group"].includes(evaluation.subject.event) && + (!Number.isInteger(evaluation.subject.pullRequest) || evaluation.subject.pullRequest < 1) + ) { + diagnostics.push(diagnostic("EVD-SCHEMA-001", "subject.pullRequest is required", "subject.pullRequest", "provide the PR number")); + } + } + if (isObject(evaluation.contract)) { + if (!/^ticket-[0-9]{3,}$/.test(evaluation.contract.ticket || "")) { + diagnostics.push(diagnostic("EVD-SCHEMA-001", "contract.ticket is invalid", "contract.ticket", "use ticket-NNN")); + } + if (!Array.isArray(evaluation.contract.criteria) || evaluation.contract.criteria.length === 0) { + diagnostics.push(diagnostic("EVD-SCHEMA-001", "contract.criteria must not be empty", "contract.criteria", "declare required criteria")); + } + } + if (isObject(evaluation.changeSet)) { + for (const key of ["commits", "changedPaths", "changedSymbols", "publicApiChanges", "dependencyChanges"]) { + if (!Array.isArray(evaluation.changeSet[key])) { + diagnostics.push(diagnostic("EVD-SCHEMA-001", `changeSet.${key} must be an array`, `changeSet.${key}`, "provide the v1 field")); + } + } + } + if (Array.isArray(evaluation.actors)) { + for (const actor of evaluation.actors) { + if (!isObject(actor) || typeof actor.id !== "string" || !Array.isArray(actor.contributionTypes)) { + diagnostics.push(diagnostic("EVD-SCHEMA-001", "actor entry is invalid", "actors", "provide id, role and contributionTypes")); + } + } + } + if (Array.isArray(evaluation.criteriaEvaluation)) { + const statuses = ["SATISFIED", "PARTIAL", "FAILED", "UNKNOWN", "NOT_APPLICABLE"]; + for (const criterion of evaluation.criteriaEvaluation) { + if ( + !isObject(criterion) || + !/^AC-[0-9]+$/.test(criterion.criterion || "") || + !statuses.includes(criterion.status) || + !Array.isArray(criterion.implementationEvidence) || + !Array.isArray(criterion.validationEvidence) || + !Array.isArray(criterion.missingEvidence) || + typeof criterion.confidence !== "number" || + criterion.confidence < 0 || + criterion.confidence > 1 + ) { + diagnostics.push(diagnostic("EVD-SCHEMA-001", "criterion evaluation entry is invalid", "criteriaEvaluation", "conform to the v1 criterion contract")); + } + } + } + if (isObject(evaluation.contribution) && !Array.isArray(evaluation.contribution.claims)) { + diagnostics.push(diagnostic("EVD-SCHEMA-001", "contribution.claims must be an array", "contribution.claims", "provide evidence-backed claims")); + } + return diagnostics; +} + +function findNumericScore(value, prefix = "") { + const paths = []; + if (Array.isArray(value)) { + value.forEach((item, index) => paths.push(...findNumericScore(item, `${prefix}[${index}]`))); + } else if (isObject(value)) { + for (const [key, item] of Object.entries(value)) { + const current = prefix ? `${prefix}.${key}` : key; + if (/score$/i.test(key) && typeof item === "number") paths.push(current); + paths.push(...findNumericScore(item, current)); + } + } + return paths; +} + +function globToRegExp(glob) { + let expression = "^"; + for (let index = 0; index < glob.length; index += 1) { + const char = glob[index]; + if (char === "*" && glob[index + 1] === "*" && glob[index + 2] === "/" + && (index === 0 || glob[index - 1] === "/")) { + expression += "(?:[^/]+/)*"; + index += 2; + } else if (char === "*" && glob[index + 1] === "*") { + expression += ".*"; + index += 1; + } else if (char === "*") { + expression += "[^/]*"; + } else if (char === "?") { + expression += "[^/]"; + } else { + expression += char.replace(/[\\^$+?.()|{}\[\]]/g, "\\$&"); + } + } + return new RegExp(`${expression}$`); +} + +function pathAllowed(changedPath, patterns) { + return patterns.some((pattern) => typeof pattern === "string" && globToRegExp(pattern).test(changedPath)); +} + +function git(repositoryRoot, args) { + const result = spawnSync("git", ["-C", repositoryRoot, ...args], { + encoding: "utf8", + shell: false, + }); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || "git command failed").trim()); + } + return result.stdout; +} + +function exactStringSet(left, right) { + const normalize = (items) => [...new Set(items)].sort(); + return canonical(normalize(left)) === canonical(normalize(right)); +} + +function approvalScopeDigest(evaluation) { + return sha256Bytes( + canonical({ + actor: evaluation.approval.actor, + headSha: evaluation.subject.headSha, + pullRequest: evaluation.subject.pullRequest ?? null, + repository: evaluation.subject.repository, + ticket: evaluation.contract.ticket, + }), + ); +} + +function expectedVerdict(evaluation) { + const blockingGate = Object.values(evaluation.gates).some((status) => + ["FAILED", "UNKNOWN", "WAITING"].includes(status), + ); + const blockingDimension = Object.values(evaluation.dimensions).some((status) => + ["FAILED", "INSUFFICIENT_EVIDENCE"].includes(status), + ); + const blockingCriterion = evaluation.criteriaEvaluation.some((criterion) => + ["PARTIAL", "FAILED", "UNKNOWN"].includes(criterion.status), + ); + const blockingFinding = evaluation.findings.some((finding) => finding.severity === "BLOCKING"); + const approvalMissing = evaluation.approval.status !== "VERIFIED"; + if (blockingGate || blockingDimension || blockingCriterion || blockingFinding || approvalMissing) { + return "BLOCKED"; + } + const reviewFinding = evaluation.findings.some((finding) => finding.severity === "REVIEW_REQUIRED"); + if (Object.values(evaluation.dimensions).includes("REVIEW_REQUIRED") || reviewFinding) { + return "REVIEW_REQUIRED"; + } + return "ALLOWED"; +} + +function validateEvaluation(evaluation, intent, paths, policyText) { + const diagnostics = [...validatePolicyText(policyText), ...validateMinimumShape(evaluation)]; + if (diagnostics.some((item) => item.code === "EVD-SCHEMA-001")) return diagnostics; + + const scorePaths = findNumericScore(evaluation); + if (scorePaths.length > 0) { + diagnostics.push( + diagnostic( + "EVD-SCORE-001", + "numeric score fields are forbidden because they can compensate hard failures", + scorePaths, + "use independent status dimensions and non-compensable gates", + ), + ); + } + + const subject = evaluation.subject; + const contract = evaluation.contract; + for (const [label, value] of [ + ["baseSha", subject.baseSha], + ["headSha", subject.headSha], + ["mergeBaseSha", subject.mergeBaseSha], + ]) { + if (!isSha(value)) { + diagnostics.push( + diagnostic("INT-BINDING-001", `${label} must be a full lowercase commit SHA`, label, "record the exact Git SHA"), + ); + } + } + if (contract.ticket !== intent.ticket || contract.workstream !== intent.workstream) { + diagnostics.push( + diagnostic( + "INT-BINDING-002", + "evaluation ticket/workstream does not match the approved intent", + [`evaluation=${contract.ticket}/${contract.workstream}`, `intent=${intent.ticket}/${intent.workstream}`], + "regenerate the report for the active ticket intent", + ), + ); + } + if ( + isObject(intent.delivery) && + isSha(intent.delivery.acceptedBaseSha) && + intent.delivery.acceptedBaseSha !== subject.baseSha + ) { + diagnostics.push( + diagnostic( + "INT-BASE-001", + "subject.baseSha does not match the base accepted in ticket intent", + [`evaluation=${subject.baseSha}`, `intent=${intent.delivery.acceptedBaseSha}`], + "rebase the change or obtain approval for a refreshed intent base", + ), + ); + } + + const expectedHashes = { + intentHash: sha256File(paths.intent), + policyHash: sha256File(paths.policy), + manifestLockHash: sha256File(paths.manifestLock), + }; + for (const [key, expected] of Object.entries(expectedHashes)) { + if (!isDigest(contract[key]) || contract[key] !== expected) { + diagnostics.push( + diagnostic( + "INT-HASH-001", + `${key} does not match the evaluated source`, + [`expected=${expected}`, `observed=${contract[key]}`], + "regenerate the evaluation after loading the current contract files", + ), + ); + } + } + + const declaredPaths = Array.isArray(evaluation.changeSet.changedPaths) + ? evaluation.changeSet.changedPaths + : []; + const allowedPaths = Array.isArray(intent.allowedPaths) ? intent.allowedPaths : []; + for (const changedPath of declaredPaths) { + if (!pathAllowed(changedPath, allowedPaths)) { + diagnostics.push( + diagnostic( + "GOV-SCOPE-001", + `changed path is outside intent.allowedPaths: ${changedPath}`, + changedPath, + "remove the path or obtain a fresh approved intent", + ), + ); + } + } + + if (paths.repositoryRoot) { + try { + git(paths.repositoryRoot, ["cat-file", "-e", `${subject.baseSha}^{commit}`]); + git(paths.repositoryRoot, ["cat-file", "-e", `${subject.headSha}^{commit}`]); + const observedMergeBase = git(paths.repositoryRoot, [ + "merge-base", + subject.baseSha, + subject.headSha, + ]).trim(); + if (observedMergeBase !== subject.mergeBaseSha) { + diagnostics.push( + diagnostic( + "COM-GIT-001", + "mergeBaseSha does not match Git", + [`expected=${observedMergeBase}`, `observed=${subject.mergeBaseSha}`], + "regenerate the report for the current branch base and head", + ), + ); + } + const observedPaths = git(paths.repositoryRoot, [ + "diff", + "--name-only", + "-z", + subject.mergeBaseSha, + subject.headSha, + "--", + ]) + .split("\0") + .filter(Boolean); + if (!exactStringSet(observedPaths, declaredPaths)) { + diagnostics.push( + diagnostic( + "COM-GIT-002", + "changeSet.changedPaths does not match the exact Git range", + [`git=${observedPaths.sort().join(",")}`, `report=${[...declaredPaths].sort().join(",")}`], + "extract changed paths from mergeBaseSha..headSha", + ), + ); + } + const observedCommits = git(paths.repositoryRoot, [ + "rev-list", + "--reverse", + `${subject.mergeBaseSha}..${subject.headSha}`, + ]) + .split("\n") + .filter(Boolean); + const declaredCommits = Array.isArray(evaluation.changeSet.commits) + ? evaluation.changeSet.commits + : []; + if (!exactStringSet(observedCommits, declaredCommits)) { + diagnostics.push( + diagnostic( + "COM-GIT-003", + "changeSet.commits does not match the exact Git range", + [`git=${observedCommits.join(",")}`, `report=${declaredCommits.join(",")}`], + "extract every commit from mergeBaseSha..headSha", + ), + ); + } + } catch (error) { + diagnostics.push( + diagnostic( + "COM-GIT-001", + `exact Git range could not be verified: ${error.message}`, + paths.repositoryRoot, + "provide an existing repository and reachable full SHAs", + ), + ); + } + } + + const requiredCriteria = Array.isArray(contract.criteria) ? contract.criteria : []; + const evaluations = Array.isArray(evaluation.criteriaEvaluation) + ? evaluation.criteriaEvaluation + : []; + const evaluatedCriteria = evaluations.map((item) => item.criterion); + if (!exactStringSet(requiredCriteria, evaluatedCriteria)) { + diagnostics.push( + diagnostic( + "EVD-CRITERION-001", + "criteriaEvaluation must cover every declared criterion exactly once", + [`required=${requiredCriteria.join(",")}`, `evaluated=${evaluatedCriteria.join(",")}`], + "add one evidence record for each required acceptance criterion", + ), + ); + } + if (new Set(evaluatedCriteria).size !== evaluatedCriteria.length) { + diagnostics.push( + diagnostic( + "EVD-CRITERION-001", + "criteriaEvaluation contains duplicate criteria", + evaluatedCriteria, + "keep exactly one record per criterion", + ), + ); + } + for (const criterion of evaluations) { + if ( + criterion.status === "SATISFIED" && + (!Array.isArray(criterion.implementationEvidence) || + criterion.implementationEvidence.length === 0 || + !Array.isArray(criterion.validationEvidence) || + criterion.validationEvidence.length === 0) + ) { + diagnostics.push( + diagnostic( + "EVD-CRITERION-002", + `${criterion.criterion} is SATISFIED without implementation and validation evidence`, + criterion.criterion, + "attach both evidence classes or lower the criterion status", + ), + ); + } + } + + const requiredGates = [ + "governance", + "scope", + "secrets", + "tests", + "regression", + "documentation", + "approval", + "evidenceCompleteness", + ]; + for (const gate of requiredGates) { + if (!["PASS", "FAILED", "UNKNOWN", "WAITING", "NOT_APPLICABLE"].includes(evaluation.gates[gate])) { + diagnostics.push( + diagnostic("EVD-GATE-001", `required gate ${gate} has no valid status`, gate, "set an explicit gate status"), + ); + } + } + const dimensionStatuses = [ + "PASS", + "REVIEW_REQUIRED", + "FAILED", + "INSUFFICIENT_EVIDENCE", + "NOT_APPLICABLE", + ]; + const requiredDimensions = [ + "governanceCompliance", + "intentAlignment", + "implementationCorrectness", + "projectDirection", + "changeReasonableness", + "contributionValue", + "evidenceConfidence", + ]; + for (const dimension of requiredDimensions) { + if (!dimensionStatuses.includes(evaluation.dimensions[dimension])) { + diagnostics.push( + diagnostic( + "EVD-DIMENSION-001", + `required dimension ${dimension} has no valid status`, + dimension, + "set an explicit independent dimension status", + ), + ); + } + } + + const approval = evaluation.approval; + if (approval.status === "VERIFIED") { + const sourceContract = { + "github-review": ["human", "github-api-allowlist"], + "github-app-review": ["validator-app", "github-api-allowlist"], + "signed-attestation": ["attestation-issuer", "signed-attestation"], + }[approval.source]; + if (!sourceContract || approval.actorRole !== sourceContract[0] || approval.verificationMethod !== sourceContract[1]) { + diagnostics.push( + diagnostic( + "APR-AUTHORITY-001", + "approval source, actor role and verification method are inconsistent", + `${approval.source}/${approval.actorRole}/${approval.verificationMethod}`, + "use the protected source-specific approval resolver", + ), + ); + } + if (approval.headSha !== subject.headSha) { + diagnostics.push( + diagnostic( + "APR-STALE-001", + "approval is bound to a previous headSha", + [`approval=${approval.headSha}`, `head=${subject.headSha}`], + "obtain a new independent approval for the exact current head", + ), + ); + } + const expectedScope = approvalScopeDigest(evaluation); + if ( + approval.approvalScopeHash !== expectedScope || + contract.approvalScopeHash !== expectedScope + ) { + diagnostics.push( + diagnostic( + "APR-BINDING-001", + "approvalScopeHash does not match repository, PR, head, ticket and actor", + [ + `expected=${expectedScope}`, + `approval=${approval.approvalScopeHash}`, + `contract=${contract.approvalScopeHash}`, + ], + "recreate approval evidence in the protected verifier", + ), + ); + } + if (!isDigest(approval.evidenceDigest)) { + diagnostics.push( + diagnostic( + "APR-BINDING-002", + "verified approval requires a SHA-256 evidence digest", + approval.evidenceDigest, + "bind the protected approval evidence artifact by digest", + ), + ); + } + const authors = evaluation.actors + .filter((actor) => actor.role === "author" || actor.role === "last-push-author") + .map((actor) => actor.id); + if (authors.includes(approval.actor)) { + diagnostics.push( + diagnostic( + "APR-INDEPENDENCE-001", + "approval actor is also an author or last-push author", + approval.actor, + "obtain review from an independent trusted authority", + ), + ); + } + if (evaluation.gates.approval !== "PASS") { + diagnostics.push( + diagnostic( + "APR-GATE-001", + "verified approval requires gates.approval=PASS", + evaluation.gates.approval, + "reconcile the approval gate with protected evidence", + ), + ); + } + } else if (evaluation.gates.approval === "PASS") { + diagnostics.push( + diagnostic( + "APR-GATE-001", + "approval gate cannot pass without VERIFIED approval", + approval.status, + "attach exact-head protected approval evidence", + ), + ); + } + + const derivedMerge = expectedVerdict(evaluation); + if (evaluation.verdict.merge !== derivedMerge) { + diagnostics.push( + diagnostic( + "INT-VERDICT-001", + "declared merge verdict does not match hard gates, criteria, findings and dimensions", + [`expected=${derivedMerge}`, `observed=${evaluation.verdict.merge}`], + "use the deterministic derived verdict; do not average failures", + ), + ); + } + const criteriaComplete = evaluations.every((item) => + ["SATISFIED", "NOT_APPLICABLE"].includes(item.status), + ); + const expectedCompletion = + derivedMerge === "ALLOWED" && criteriaComplete + ? "ACCEPTED" + : derivedMerge === "REVIEW_REQUIRED" && criteriaComplete + ? "CANDIDATE" + : "NOT_DONE"; + if (evaluation.verdict.completion !== expectedCompletion) { + diagnostics.push( + diagnostic( + "INT-COMPLETION-001", + "declared completion does not match accepted evidence and merge verdict", + [`expected=${expectedCompletion}`, `observed=${evaluation.verdict.completion}`], + "mark incomplete work NOT_DONE until every required criterion is accepted", + ), + ); + } + return diagnostics; +} + +function markdownReport(valid, evaluation, diagnostics, evaluationDigest) { + const lines = ["# Change Evaluation", ""]; + lines.push(`Validation: ${valid ? "PASS" : "FAILED"}`); + if (evaluation && isObject(evaluation.subject) && isObject(evaluation.contract)) { + lines.push(`Merge verdict: ${evaluation.verdict?.merge || "UNKNOWN"}`); + lines.push(`Completion: ${evaluation.verdict?.completion || "UNKNOWN"}`); + lines.push(`Ticket: ${evaluation.contract.ticket || "UNKNOWN"}`); + lines.push(`Base: ${evaluation.subject.baseSha || "UNKNOWN"}`); + lines.push(`Head: ${evaluation.subject.headSha || "UNKNOWN"}`); + lines.push(`Intent hash: ${evaluation.contract.intentHash || "UNKNOWN"}`); + lines.push(`Policy hash: ${evaluation.contract.policyHash || "UNKNOWN"}`); + } + lines.push(`Evaluation digest: ${evaluationDigest || "UNAVAILABLE"}`, ""); + lines.push(`Blocking diagnostics: ${diagnostics.length}`); + if (diagnostics.length > 0) { + lines.push("", "## Diagnostics", ""); + for (const item of diagnostics) lines.push(`- ${item.code}: ${item.message}`); + } + return `${lines.join("\n")}\n`; +} + +function writeResult(options, envelope, markdown) { + const json = `${JSON.stringify(sortDeep(envelope), null, 2)}\n`; + if (options.has("--json-out")) fs.writeFileSync(path.resolve(options.get("--json-out")), json); + if (options.has("--markdown-out")) { + fs.writeFileSync(path.resolve(options.get("--markdown-out")), markdown); + } + process.stdout.write(json); +} + +if (command === "help" || command === "--help" || command === "-h") usage(); + +const options = parseOptions(argv); +const policyPath = path.resolve(options.get("--policy") || path.join(packagedRoot, "CONTRIBUTING.md")); + +if (command === "policy") { + const policyText = readText(policyPath, "policy"); + const diagnostics = validatePolicyText(policyText).sort((left, right) => + `${left.code}:${left.message}`.localeCompare(`${right.code}:${right.message}`), + ); + const result = { + schemaVersion: "t2c.change-evaluation-policy-validation/v1", + valid: diagnostics.length === 0, + policyHash: sha256File(policyPath), + ruleIds: requiredEvaluationRules, + diagnostics, + }; + process.stdout.write(`${JSON.stringify(sortDeep(result), null, 2)}\n`); + process.exit(result.valid ? 0 : 1); +} + +if (command !== "validate") usage(`unknown command ${command}`); +for (const required of ["--evaluation", "--intent", "--manifest-lock"]) { + if (!options.has(required)) usage(`${required} is required`); +} + +const paths = { + evaluation: path.resolve(options.get("--evaluation")), + intent: path.resolve(options.get("--intent")), + manifestLock: path.resolve(options.get("--manifest-lock")), + policy: policyPath, + repositoryRoot: options.has("--repository-root") + ? path.resolve(options.get("--repository-root")) + : null, +}; + +let evaluation; +let intent; +let diagnostics = []; +try { + evaluation = readJson(paths.evaluation, "evaluation"); + intent = readJson(paths.intent, "intent"); + readJson(paths.manifestLock, "manifest lock"); + const policyText = readText(paths.policy, "policy"); + diagnostics = validateEvaluation(evaluation, intent, paths, policyText); +} catch (error) { + diagnostics = [ + diagnostic( + "EVD-INPUT-001", + error.message, + "runtime input", + "provide readable, valid contract inputs", + ), + ]; +} + +diagnostics.sort((left, right) => + `${left.code}:${left.message}`.localeCompare(`${right.code}:${right.message}`), +); +let evaluationDigest = null; +if (evaluation !== undefined) { + const digestSubject = JSON.parse(JSON.stringify(evaluation)); + if (isObject(digestSubject.provenance)) delete digestSubject.provenance.evaluationDigest; + evaluationDigest = sha256Bytes(canonical(digestSubject)); +} +const valid = diagnostics.length === 0; +const mergeAllowed = valid && evaluation?.verdict?.merge === "ALLOWED"; +const envelope = { + schemaVersion: "t2c.change-evaluation-validation/v1", + valid, + mergeAllowed, + evaluationDigest, + verdict: evaluation?.verdict || null, + diagnostics, +}; +writeResult(options, envelope, markdownReport(valid, evaluation, diagnostics, evaluationDigest)); +process.exit(mergeAllowed ? 0 : 1); +TYPESCRIPT diff --git a/wellmanifest_governance.py b/wellmanifest_governance.py new file mode 100644 index 0000000..f14bc20 --- /dev/null +++ b/wellmanifest_governance.py @@ -0,0 +1,175 @@ +"""Pytest lifecycle bridge for the adopted wellmanifest governance gate.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +class GovernanceGateError(RuntimeError): + """Raised when deterministic governance rejects the current checkout.""" + + +def _git(root: Path, *args: str) -> str | None: + result = subprocess.run( + ["git", *args], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if result.returncode: + return None + return result.stdout.strip() + + +def _activate_managed_hook(root: Path) -> None: + """Activate the installed clone-local hook before enforcing the gate.""" + contract_path = root / ".governance" / "agent-hosts.json" + if not contract_path.is_file(): + return + try: + contract = json.loads(contract_path.read_text(encoding="utf-8")) + hook = contract["hook"] + hook_path = hook["path"] + hooks_config = hook["hooksPathConfig"] + except (OSError, UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError): + return + if not all(isinstance(value, str) and value for value in (hook_path, hooks_config)): + return + hook_file = Path(hook_path) + config_path = Path(hooks_config) + if ( + hook_file.is_absolute() + or config_path.is_absolute() + or ".." in hook_file.parts + or ".." in config_path.parts + or not (root / hook_file).is_file() + ): + return + subprocess.run( + ["git", "config", "--local", "core.hooksPath", hooks_config], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + + +def _github_event_base(root: Path) -> str | None: + event_path = os.environ.get("GITHUB_EVENT_PATH", "").strip() + if not event_path: + return None + try: + event = json.loads(Path(event_path).read_text(encoding="utf-8")) + base = event["pull_request"]["base"]["sha"] + except (OSError, UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError): + return None + if not isinstance(base, str) or re.fullmatch(r"[0-9a-f]{40}", base) is None: + return None + if _git(root, "cat-file", "-e", f"{base}^{{commit}}") is None: + subprocess.run( + ["git", "fetch", "--no-tags", "--depth=1", "origin", base], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if _git(root, "cat-file", "-e", f"{base}^{{commit}}") is not None: + return base + return None + + +def _resolve_base(root: Path) -> str: + explicit = os.environ.get("WELLMANIFEST_BASE_SHA", "").strip() + if explicit and _git(root, "cat-file", "-e", f"{explicit}^{{commit}}") is not None: + return explicit + + github_event_base = _github_event_base(root) + if github_event_base: + return github_event_base + + candidates: list[str] = [] + github_base = os.environ.get("GITHUB_BASE_REF", "").strip() + if github_base: + candidates.append(f"origin/{github_base}") + candidates.append("origin/main") + for candidate in candidates: + if _git(root, "rev-parse", "--verify", f"{candidate}^{{commit}}") is None: + continue + merge_base = _git(root, "merge-base", "HEAD", candidate) + if merge_base: + return merge_base + + raise GovernanceGateError( + "GOV-PACKAGING-003: cannot resolve Git base; " + "set WELLMANIFEST_BASE_SHA or fetch the target branch" + ) + + +def _changed_paths(root: Path, base: str) -> list[str]: + paths: set[str] = set() + commands = ( + ("diff", "--name-only", base, "HEAD"), + ("diff", "--name-only"), + ("diff", "--cached", "--name-only"), + ("ls-files", "--others", "--exclude-standard"), + ) + for command in commands: + output = _git(root, *command) + if output: + paths.update(line for line in output.splitlines() if line) + return sorted(paths) + + +def pytest_sessionstart(session: object) -> None: + """Run repository governance once before pytest executes product tests.""" + config = getattr(session, "config", None) + options = getattr(config, "option", None) + if bool(getattr(options, "collectonly", False)): + return + rootpath = getattr(config, "rootpath", Path.cwd()) + root = Path(str(rootpath)).resolve() + gate_sh = root / "project" / "governance-check.sh" + gate_bat = root / "project" / "governance-check.bat" + if not gate_sh.is_file() and not (sys.platform.startswith("win") and gate_bat.is_file()): + raise GovernanceGateError( + "GOV-PACKAGING-003: managed governance gate for this platform is missing" + ) + if os.environ.get("WELLMANIFEST_GOVERNANCE_ACTIVE") == "1": + raise GovernanceGateError("GOV-PACKAGING-003: recursive gate invocation") + + _activate_managed_hook(root) + base = _resolve_base(root) + if sys.platform.startswith("win"): + if gate_bat.is_file(): + command = [os.environ.get("COMSPEC", "cmd.exe"), "/c", str(gate_bat), "--base", base] + else: + command = ["bash", str(gate_sh), "--base", base] + else: + command = [str(gate_sh), "--base", base] + for path in _changed_paths(root, base): + command.extend(("--changed-file", path)) + + environment = dict(os.environ) + environment["WELLMANIFEST_GOVERNANCE_ACTIVE"] = "1" + result = subprocess.run( + command, + cwd=root, + env=environment, + capture_output=True, + text=True, + check=False, + ) + if result.stdout: + sys.stdout.write(result.stdout) + if result.stderr: + sys.stderr.write(result.stderr) + if result.returncode: + raise GovernanceGateError( + f"GOV-PACKAGING-003: governance gate failed with exit code {result.returncode}" + ) diff --git a/worktree-guard.yaml b/worktree-guard.yaml new file mode 100644 index 0000000..6768c47 --- /dev/null +++ b/worktree-guard.yaml @@ -0,0 +1,65 @@ +schema: wellmanifest.worktree-guard/v1 +pipeline: + name: worktree-overlap-guard + description: > + Fail closed when two or more worktrees of the same repository identity + change the same paths, or when IN_PROGRESS intents overlap without + conflictsWith. Companion to workspace_lifecycle_check.py (terminal cleanup) + and to pyqual.yaml / goal.yaml style enforcement. + + detect: + min_worktrees: 2 + same_identity: true + workspace_roots: + - . + - worktrees + - .worktrees + - ../.worktrees + + compare: + git: true + intents: true + code2llm: optional + + ignore: + - TODO.md + - project/TICKETS.md + - project/ticket-*/** + - node_modules/** + - .venv/** + - venv/** + - __pycache__/** + + enforce: + on_overlap: fail_closed + require_conflictsWith: true + + triggers: + - kind: once + - kind: interval + seconds: 300 + - kind: worktree-change + - kind: pre-commit + - kind: governance-check + - kind: systemd-timer + seconds: 300 + - kind: systemd-path + + command: python3 .governance/worktree_overlap_check.py --workspace-root {workdir} + +# Run the managed, read-only guard: +# once: python3 .governance/worktree_guard.py --root . --once +# watch: python3 .governance/worktree_guard.py --root . --watch --interval 300 +# +# Optional pyqual.yaml snippet, also printed by +# `python3 .governance/worktree_guard.py --print-pyqual-stage`: +# +# custom_tools: +# - name: worktree_guard +# binary: python3 +# command: python3 .governance/worktree_guard.py --root {workdir} --once +# allow_failure: false +# stages: +# - name: worktree-overlap +# tool: worktree_guard +# optional: false