From 33bc94c4d3bfed248d4d2bf1277774531c2a4985 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sat, 19 Sep 2026 21:31:11 +0200 Subject: [PATCH] chore(governance): adopt wellmanifest/new-project 0.20.32 (ticket-170) --- .aider.conf.yml | 12 + .cursor/rules/new-project-standard.mdc | 19 + .github/copilot-instructions.md | 19 + .governance/AGENT_DECISIONS.md | 44 ++ .governance/agent-hosts.json | 50 +- .governance/agent-hosts.schema.json | 98 +++- .governance/agent_host_check.py | 333 ++++++++++- .governance/branch_lifecycle_check.py | 14 +- .governance/decision_record.py | 45 +- .governance/diagnostics.json | 71 ++- .governance/docs/SNAPSHOT_MIGRATION.md | 120 ++++ .governance/error/GOV-AGENT-HOST.md | 7 + .governance/error/GOV-APPROVAL.md | 101 ++++ .governance/error/GOV-ARCHITECTURE-001.md | 67 +++ .governance/error/GOV-SNAPSHOT-MIGRATION.md | 45 ++ .governance/error/GOV-WORK-START.md | 183 +++++++ .governance/error/GOV-WORKSPACE-LIFECYCLE.md | 59 +- .governance/governance_check.py | 144 ++++- .governance/intent.schema.json | 58 +- .governance/manifest.base.json | 2 +- .governance/manifest.json | 24 +- .governance/manifest.lock.json | 63 ++- .governance/package-manifest.json | 54 ++ .governance/precommit_standard_update.py | 104 ---- .governance/snapshot-migration.schema.json | 139 +++++ .governance/snapshot_migration.py | 256 +++++++++ .governance/ticket_activity.py | 122 ++++- .governance/ticket_storage.py | 48 +- .governance/work-start-report.schema.json | 389 +++++++++++++ .governance/work_start_check.py | 546 +++++++++++++++++++ .governance/worktree_overlap_check.py | 43 +- AGENTS.md | 72 ++- CLAUDE.md | 19 + GEMINI.md | 19 + project/TICKETS.md | 3 + project/new-ticket.sh | 52 +- project/ticket-170/README.md | 30 + project/ticket-170/intent.json | 108 ++++ pyproject.toml | 4 +- scripts/install-agent-hosts.sh | 48 +- 40 files changed, 3400 insertions(+), 234 deletions(-) create mode 100644 .governance/docs/SNAPSHOT_MIGRATION.md create mode 100644 .governance/error/GOV-APPROVAL.md create mode 100644 .governance/error/GOV-ARCHITECTURE-001.md create mode 100644 .governance/error/GOV-SNAPSHOT-MIGRATION.md create mode 100644 .governance/error/GOV-WORK-START.md create mode 100644 .governance/snapshot-migration.schema.json create mode 100755 .governance/snapshot_migration.py create mode 100644 .governance/work-start-report.schema.json create mode 100755 .governance/work_start_check.py create mode 100644 project/ticket-170/README.md create mode 100644 project/ticket-170/intent.json diff --git a/.aider.conf.yml b/.aider.conf.yml index 348a7c7..f133379 100644 --- a/.aider.conf.yml +++ b/.aider.conf.yml @@ -1,3 +1,11 @@ +# +# 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. @@ -8,3 +16,7 @@ read: # 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 index 42e4aad..8298ad6 100644 --- a/.cursor/rules/new-project-standard.mdc +++ b/.cursor/rules/new-project-standard.mdc @@ -3,6 +3,21 @@ description: wellmanifest/new-project fail-closed ticket contract for every Curs 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: @@ -15,3 +30,7 @@ Before writing code in this repository: 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/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8c2e556..1b5cdd1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,20 @@ # 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. @@ -15,3 +30,7 @@ rule. Copilot Chat and the Copilot coding agent load this file automatically. 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/.governance/AGENT_DECISIONS.md b/.governance/AGENT_DECISIONS.md index e309a70..6836002 100644 --- a/.governance/AGENT_DECISIONS.md +++ b/.governance/AGENT_DECISIONS.md @@ -29,6 +29,7 @@ observation. It grants no new Git, deployment, credential or cleanup authority. | 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. | @@ -55,6 +56,49 @@ 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 diff --git a/.governance/agent-hosts.json b/.governance/agent-hosts.json index 2eb64f6..eb58915 100644 --- a/.governance/agent-hosts.json +++ b/.governance/agent-hosts.json @@ -1,6 +1,6 @@ { "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, 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.", + "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", @@ -58,6 +58,54 @@ ] } ], + "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", diff --git a/.governance/agent-hosts.schema.json b/.governance/agent-hosts.schema.json index a1b3d77..19d1d13 100644 --- a/.governance/agent-hosts.schema.json +++ b/.governance/agent-hosts.schema.json @@ -5,7 +5,7 @@ "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", "packaging", "declarationFields"], + "required": ["schema", "hook", "hosts", "sourceLinks", "anomalyChecks", "packaging", "declarationFields"], "properties": { "schema": { "const": "new-project.agent-hosts/v1" }, "note": { "type": "string" }, @@ -42,6 +42,102 @@ } } }, + "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": "^[^/][^\\\\]*$" } + } + } + }, + "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": "^[^/][^\\\\]*$" }, + "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, diff --git a/.governance/agent_host_check.py b/.governance/agent_host_check.py index de9feca..aedf998 100644 --- a/.governance/agent_host_check.py +++ b/.governance/agent_host_check.py @@ -13,6 +13,7 @@ import argparse import json import os +import re import subprocess import sys from dataclasses import dataclass, field @@ -34,6 +35,7 @@ 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) @@ -98,6 +100,133 @@ def check_hosts(root: Path, contract: dict[str, Any]) -> list[Finding]: return findings +def check_source_links(root: Path, contract: dict[str, Any]) -> 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 = [ + str(item["path"]) + for item in local + if isinstance(item, dict) + and isinstance(item.get("path"), str) + and (root / str(item["path"])).is_file() + ] + if not local_paths: + findings.append(Finding( + "GOV-AGENT-HOST-004", + "No declared local source link resolves in this checkout.", + "Restore the local adoption lock/package or the hub manifest/package before using host instructions.", + ["sourceLinks"], + )) + return 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"]) @@ -251,6 +380,206 @@ def check_declaration( return findings +def workflow_job_names(path: Path) -> list[str]: + """Parse the small, stable subset of GitHub workflow YAML we need. + + The required-checks validator owns the complete workflow contract. This + deliberately remains a narrow, dependency-free preflight so an agent-host + audit can flag an impossible CI declaration before a long session starts. + """ + lines = path.read_text(encoding="utf-8").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 not None: + 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((" ", "\t")) + and line.strip() and not line.lstrip().startswith("#")): + break + match = re.match(r"^ ([A-Za-z0-9][A-Za-z0-9_-]*):\s*(?:#.*)?$", line) + if match: + flush() + current_key = match.group(1) + continue + name = re.match(r"^ name:\s*(.+?)\s*$", line) + if name and current_key is not None and current_name is None: + value = name.group(1).strip() + if " #" in value: + value = value.split(" #", 1)[0].rstrip() + if len(value) >= 2 and value[0] in {"'", '"'} and value[-1] == value[0]: + value = value[1:-1] + current_name = value + flush() + return jobs + + +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): + published = [] + 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) @@ -280,7 +609,7 @@ def load_contract(root: Path, explicit: str | None) -> tuple[dict[str, Any] | No "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", "packaging"): + 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.", @@ -296,8 +625,10 @@ def audit(root: Path, actor: str = "agent", contract_path: str | None = None) -> else: findings = ( check_hosts(root, contract) + + check_source_links(root, contract) + check_hook(root, contract, actor) + check_packaging(root, contract) + + check_guidance_anomalies(root, contract) ) findings.sort() return { diff --git a/.governance/branch_lifecycle_check.py b/.governance/branch_lifecycle_check.py index 8838f2f..4fb3101 100755 --- a/.governance/branch_lifecycle_check.py +++ b/.governance/branch_lifecycle_check.py @@ -155,8 +155,8 @@ def evaluate(snapshot: dict[str, Any]) -> list[Finding]: findings.append(Finding( code="GOV-BRANCH-LIFECYCLE-003", severity="error", - message="The snapshot is inconsistent: an internal open PR head is missing.", - remediation="Re-acquire one atomic snapshot and verify the open PR head branches.", + 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}, )) @@ -168,10 +168,10 @@ def evaluate(snapshot: dict[str, Any]) -> list[Finding]: severity="error", message="Remote branches exist without ownership by an open pull request.", remediation=( - "Open a bounded ticket pull request for each branch or obtain an explicit owner " - "decision to discard the unmerged branch after preserving history and reconciling " - "every accepted criterion with branch_intent_reconciliation.py. " - "Unknown evidence is not permission to discard." + "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}, )) @@ -226,7 +226,7 @@ def main(argv: list[str] | None = None) -> int: 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.", + remediation="Re-acquire the snapshot from the protected GitHub workflow; preserve refs while the observation is unresolved.", evidence={"reason": str(error)}, )] diff --git a/.governance/decision_record.py b/.governance/decision_record.py index 7149a80..ffd9fcf 100755 --- a/.governance/decision_record.py +++ b/.governance/decision_record.py @@ -17,6 +17,40 @@ 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*(.+)$") @@ -351,6 +385,11 @@ 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) @@ -365,6 +404,9 @@ def main(argv: list[str] | None = None) -> int: 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) @@ -373,7 +415,8 @@ def main(argv: list[str] | None = None) -> int: for e in errors: print(e, file=sys.stderr) return 1 - print("OK", record["decisionId"], record["verdict"]) + 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")) diff --git a/.governance/diagnostics.json b/.governance/diagnostics.json index c2c65fb..cf6a3ec 100644 --- a/.governance/diagnostics.json +++ b/.governance/diagnostics.json @@ -1,9 +1,14 @@ { "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": "Allocate through ./project/new-ticket.sh and commit on a branch whose name contains ticket-NNN.", + "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": { @@ -38,33 +43,33 @@ }, "GOV-APPROVAL-001": { "message": "Implementation lacks approval from a trusted external source.", - "remediation": "Obtain an exact-head approval from an allowlisted human, trusted Validator App or verified signed attestation.", - "documentation": null + "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": "Approve the current ticket after reviewing its latest intent and exact implementation head.", - "documentation": null + "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": null + "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": null + "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": null + "documentation": "error/GOV-APPROVAL.md" }, "GOV-ARCHITECTURE-001": { "message": "Architecture ownership, UI/data impact or component mapping is unresolved.", - "remediation": "Complete the accepted architecture block in intent.json before implementation.", - "documentation": null + "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.", @@ -82,19 +87,19 @@ "documentation": null }, "GOV-BRANCH-LIFECYCLE-001": { - "message": "GitHub is not configured to delete merged head branches.", + "message": "GitHub automatic head-branch deletion after merge is disabled.", "remediation": "Set delete_branch_on_merge=true in repository settings.", - "documentation": null + "documentation": "error/GOV-WORKSPACE-LIFECYCLE.md" }, "GOV-BRANCH-LIFECYCLE-002": { - "message": "The remote branch snapshot is inconsistent with open pull requests.", - "remediation": "Acquire one fresh protected snapshot and verify each open PR head branch.", - "documentation": null + "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": "A quiescent repository has a remote branch other than its default branch.", - "remediation": "Preserve unmerged work, then delete only a verified merged or explicitly discarded remote branch.", - "documentation": null + "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.", @@ -465,6 +470,36 @@ "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" } } } diff --git a/.governance/docs/SNAPSHOT_MIGRATION.md b/.governance/docs/SNAPSHOT_MIGRATION.md new file mode 100644 index 0000000..f861a94 --- /dev/null +++ b/.governance/docs/SNAPSHOT_MIGRATION.md @@ -0,0 +1,120 @@ +--- +{ + "schema": "wellmanifest.docs/document/v1", + "id": "snapshot-migration", + "kind": "information", + "version": 1, + "title": "One-time lossless snapshot migration", + "status": "proposed", + "owner": "wellmanifest/new-project", + "created": "2026-09-14", + "updated": "2026-09-14", + "review_after": "2026-09-21", + "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. + +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. + + +## 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 and unchanged ordinary 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 index d74f488..d3aeb44 100644 --- a/.governance/error/GOV-AGENT-HOST.md +++ b/.governance/error/GOV-AGENT-HOST.md @@ -9,6 +9,11 @@ emitowane przez `.githooks/pre-commit`, oraz `GOV-AGENT-HOST-004`, 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. + ## Situation `001`–`003` oraz `007` pojawiają się przy commicie: branch nie jest związany z ticketem @@ -43,6 +48,8 @@ rzeczywistości sprawdzany. registry. Dla `003` nie twórz commita: chroniony kontroler dostawy zapisuje terminalny receipt poza checkoutem autora. 4. Potwierdź stan: `python3 scripts/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 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..49cf403 --- /dev/null +++ b/.governance/error/GOV-ARCHITECTURE-001.md @@ -0,0 +1,67 @@ +# 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. +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-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-WORK-START.md b/.governance/error/GOV-WORK-START.md new file mode 100644 index 0000000..04a5744 --- /dev/null +++ b/.governance/error/GOV-WORK-START.md @@ -0,0 +1,183 @@ +# 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; no automatic takeover. | +| 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. + +## 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. diff --git a/.governance/error/GOV-WORKSPACE-LIFECYCLE.md b/.governance/error/GOV-WORKSPACE-LIFECYCLE.md index 687a667..eefc147 100644 --- a/.governance/error/GOV-WORKSPACE-LIFECYCLE.md +++ b/.governance/error/GOV-WORKSPACE-LIFECYCLE.md @@ -6,17 +6,61 @@ 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. + 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. @@ -32,8 +76,12 @@ GitHub są osobnymi granicami dowodu. - Lokalny workspace checker kończy się `GOV-WORKSPACE-PASS` bez nieallowlistowanych checkoutów. -- Osobny workflow GitHub potwierdza tylko `main`, brak otwartych PR i - `delete_branch_on_merge=true`. +- 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 @@ -42,8 +90,13 @@ GitHub są osobnymi granicami dowodu. - 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/governance_check.py b/.governance/governance_check.py index a1ceaea..7a60ec6 100755 --- a/.governance/governance_check.py +++ b/.governance/governance_check.py @@ -108,6 +108,7 @@ class Report: def __init__(self, root: Path) -> None: self.root = root self.findings: list[Finding] = [] + self.snapshot_migrations: dict[str, dict[str, Any]] = {} def add( self, @@ -616,6 +617,47 @@ def delivery_ui_impact_error(impact: str, states: list[str], evidence: list[str] 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", @@ -630,10 +672,11 @@ def delivery_architecture_error(architecture: Any) -> str | None: return f"delivery architecture {name} is blank" if not isinstance(architecture.get("responsibilityChanges"), bool): return "delivery responsibilityChanges must be boolean" - for name in ("interfaceChanges", "dataChanges"): - if not string_list(architecture.get(name)): - return f"delivery architecture {name} must be a unique string list" - return delivery_components_error(architecture.get("components")) or delivery_ui_error(architecture.get("ui")) + 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: @@ -797,16 +840,74 @@ def standard_adoption_error(value: Any) -> str | None: 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), + ) + except (OSError, ValueError, TypeError, KeyError, UnicodeError) 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", } - if not isinstance(value, dict) or set(value) not in { - frozenset(required_fields), frozenset({*required_fields, "standardAdoption"}), - }: + 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" + 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 @@ -1000,7 +1101,11 @@ def check_history_order( if not base: return try: - commits = git_output(root, ["rev-list", "--reverse", f"{base}..{head}"]).decode().splitlines() + 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.", @@ -2986,6 +3091,10 @@ def check_actual_delivery_budget( ) -> 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 ( @@ -3020,13 +3129,16 @@ def check_integration_ownership( ) -> None: integration_workstream = manifest["coordination"]["integration"]["workstream"] architecture = delivery["architecture"] - if (architecture["responsibilityChanges"] or architecture["dataChanges"]) and record.intent["workstream"] != integration_workstream: + 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 an explicit integration-workstream contract before changing component ownership or persistent data.", + "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}, + {"workstream": record.intent["workstream"], "requiredWorkstream": integration_workstream, + "responsibilityChanges": architecture["responsibilityChanges"], + "integrationDataChanges": data_changes}, ) @@ -3974,6 +4086,9 @@ def parse_args(argv: list[str]) -> argparse.Namespace: 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("--format", choices=["text", "json", "sarif"], default="text") @@ -4131,6 +4246,13 @@ def run_governance_checks( directories = [record.directory for record in records] base = resolve_validation_base(args.base, root, records, manifest["ticket"], args.head) changed = 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] + changed = sorted(set(changed) | migration_repairs) active = active_ticket_records(root, manifest["ticket"], records, report) changed_active = [ record for record in active diff --git a/.governance/intent.schema.json b/.governance/intent.schema.json index 448b6b0..33823fb 100644 --- a/.governance/intent.schema.json +++ b/.governance/intent.schema.json @@ -86,6 +86,48 @@ }, "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, @@ -220,7 +262,21 @@ }, "dataChanges": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "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": { diff --git a/.governance/manifest.base.json b/.governance/manifest.base.json index eb6b37e..13ec025 100644 --- a/.governance/manifest.base.json +++ b/.governance/manifest.base.json @@ -110,7 +110,7 @@ "stacks": [], "standard": { "id": "wellmanifest/new-project", - "version": "0.20.25" + "version": "0.20.32" }, "ticket": { "activeStatuses": [ diff --git a/.governance/manifest.json b/.governance/manifest.json index fdd78e5..9fecdca 100644 --- a/.governance/manifest.json +++ b/.governance/manifest.json @@ -35,26 +35,32 @@ "app/**", "lib/**", "test/**", - "tests/**", - ".gitignore", - ".governance/manifest.json", - ".governance/manifest.lock.json", - ".governance/ticket-allocation.json", - ".governance/required-checks.json" + "tests/**" ] }, "governance": { "ownedPaths": [ - ".governance/**", + ".aider.conf.yml", + ".cursor/rules/**", + ".githooks/**", + ".github/copilot-instructions.md", + ".github/workflows/new-project-governance.yml", ".gitignore", + ".governance/**", + ".subactor/**", "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", "README.md", "TODO.md", "goal.yaml", "project/**", "project.sh", "project.bat", - "scripts/runtime.sh" + "scripts/install-agent-hosts.sh", + "scripts/runtime.sh", + "wellmanifest_governance.py", + "worktree-guard.yaml" ] }, "infrastructure": { @@ -198,7 +204,7 @@ ], "standard": { "id": "wellmanifest/new-project", - "version": "0.20.25" + "version": "0.20.32" }, "ticket": { "activeStatuses": [ diff --git a/.governance/manifest.lock.json b/.governance/manifest.lock.json index 062f582..e666d2b 100644 --- a/.governance/manifest.lock.json +++ b/.governance/manifest.lock.json @@ -1,55 +1,62 @@ { "managedFiles": { - ".aider.conf.yml": "756af4477d7a0d39361919c957ee954807259f15cabf4dcabde81677eac6efd6", - ".cursor/rules/new-project-standard.mdc": "72699a5cb3718be9a50603eda9c8a6d960f21b1949fdfe2b35013385b22f9afe", + ".aider.conf.yml": "6de9c74f752e8a879419698531ac575a1f2a3b93efd38a257ac1c48682a26bdd", + ".cursor/rules/new-project-standard.mdc": "8884003b3a4470e4677ca5128645f466021432ceb3f018a9f1aa743210f4181d", ".githooks/pre-commit": "bde7345b3a1a726eaf6dfd18403a8e917c0544ddfb929e4f8f10c39e58eb8a0f", - ".github/copilot-instructions.md": "b31f9ba957ce108a83851f48f8dbff8b4301d1c744a77fe76914e6bb8499aca3", + ".github/copilot-instructions.md": "08330b67c95c3c175573fe1bec06c21a2f49e6c419164d04980d4f81b4efb8a8", ".github/workflows/new-project-governance.yml": "b41d0df4cb11de5bd1458976e0a1f69ee6733593cd9bb93d68fbbb7fe2e52929", - ".governance/AGENT_DECISIONS.md": "fb8fcbb00ba4100ec230aea34ef4634852dae30929eb9eaeafe548fbc7b1de87", + ".governance/AGENT_DECISIONS.md": "853c5282e44321cd5cd895f41627fc4c5bb98774cd6e726ee1ccf319582f8407", ".governance/adoption-bindings.json": "9a6015fde26226d4c55764c81c1ab8e8d42fcf64661533a71d90fcc488edaeb3", ".governance/adoption-bindings.schema.json": "0fbad765ca67924b7b3340dcad09f262b62c979740a291da7dacd8e48d28241c", - ".governance/agent-hosts.json": "d4565102018a634307d941173fdea5da04c2cce494a979199a463d318dab41a0", - ".governance/agent-hosts.schema.json": "46a3e39eb35f5ad34363a5aa1394cc0db2304f48ee8d816c80615dc565c3c360", - ".governance/agent_host_check.py": "a6d2103122163688b263a86bb0dfd9c8ec04274d59d2308b2afa44b89d92b0cb", + ".governance/agent-hosts.json": "37562366bcd3a1d3a232f258adcdde02c66bc50ae9b4da38c96196321797b3db", + ".governance/agent-hosts.schema.json": "2e069dc7af1ecd62ba3e0846ad2c050208d465bc8adaf9be13d350c77310cc2a", + ".governance/agent_host_check.py": "ffdd5802c01bb2f5d71218e4cea0544e332788508b3e7bf2b6aec856422ac46d", ".governance/approval-evidence.schema.json": "e0f79eb7bdb534ec17e1a94a56b06a371df14b6f6b9f34db6cf4f8ee059718d5", ".governance/branch-intent-reconciliation.schema.json": "8d770fc7c81884844c3218cc1f18c11ed9c9f7851f2769e040eec676e8ea8006", ".governance/branch_intent_reconciliation.py": "cf316043eaff77021183d64f5b6f8b291fb0c38b62a9bae7203845a8b35592ca", - ".governance/branch_lifecycle_check.py": "c37427a28fbb6826874584bb25fd992784c33b4600f1d77815f68a48b1acc543", + ".governance/branch_lifecycle_check.py": "bf354a796e23334b0a2eccfac26fac99f37e4e562adc85793188eca2aaaaf931", ".governance/change-evaluation.schema.json": "69af6aa537dd3957d9cdc6ff19edb1ac8c8710f798e5255219422547d84fa2d3", ".governance/change-lease.schema.json": "f9b8eabf4d66ced63fbb1c5d8ae733f88bcf64016c8f9cf101eb4a17533406c6", ".governance/change_lease_check.py": "33d3435fbf2057442a9d31a03b676862aee8506a2ff47c595cb41aa05ec6c491", ".governance/check_required_checks.py": "6ca9980ad55a5677e938dd52c8647437296493eb0d57d775997526e6e96998af", ".governance/decision-record.schema.json": "9300b57ee9c1823adb1b2ddbf0eb86827b270c5ca030a91d69eefbf042e034a9", - ".governance/decision_record.py": "ffbdf4f8d1184e210b5dc824c7b889e56523ebd6351b323b14e7ff23abb063f5", - ".governance/diagnostics.json": "f132973d5becc5393355f1bb60673c29522eb61f1bea0956d7523214668edd67", + ".governance/decision_record.py": "cc1fafe5ad4f6586188296ade2dac7d2aef4456f5ff2a36de8c399337d173878", + ".governance/diagnostics.json": "96a4e79250a38cf53e3329d274021ddbee4f275bdb27c38d013cfb6d2dd58a17", ".governance/diagnostics.schema.json": "5c28e6a54319d106c234e63e1f65933533b5bfd5cdf6509a17b28362b56f86e1", ".governance/docs/BRANCH_INTENT_RECONCILIATION.md": "d17163b016e1ad8cf97408d528bf5a6443e931ea694e97191abc7c1e50586c3b", ".governance/docs/LOCAL_CI_PUBLICATION.md": "f30b778c8dc777be302ff346d3dbd144fc3a02fc957b178cc600bbec1219fc64", - ".governance/error/GOV-AGENT-HOST.md": "a3679a63e6346946382dc510a8454b5c1c2851e694f7b3f4b82473764153a90f", + ".governance/docs/SNAPSHOT_MIGRATION.md": "fdf7601dc1f513b7d5bc2468f5b1dcea6dd5923d7bc32b50b147ab2620d768af", + ".governance/error/GOV-AGENT-HOST.md": "ba4ad66e68705a9ab0b43a9864ffaaa2399ddaddc91c5f25202803c058e320d9", + ".governance/error/GOV-APPROVAL.md": "3b508f23491f2ad93e115481699fab84ef3def844f7580736c29c8afafb1ec47", + ".governance/error/GOV-ARCHITECTURE-001.md": "c2b9e480ccb5e15023c1592d9d81a6c8532a87fdd5ab93719fccd2c5d75e7a08", ".governance/error/GOV-CHANGE-LEASE.md": "30530e7fd7c2eca9f4db0adec575dbc9b1bea139c2a640bfde0ae7c4b2ace116", ".governance/error/GOV-INTENT.md": "4dc29dbf4c39d18cd11a5ec1eccc257a06d2bea2f95b560aa58085672611d4a9", ".governance/error/GOV-PACKAGING.md": "4a602c65a655e9b8b631487fa24982df00e0ae874cf5bdb5129f6493bb440c89", ".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": "09f92cac24bbbbe5c2967221497fb6b68b02bcd3bf4f56afe36d36ac7d7b0a58", ".governance/error/GOV-WORK-CONTINUITY.md": "de46a4adc51e8dd5880a83a6d9585ecdbbea4ae30a44ee6506c207ea2e8bc8eb", - ".governance/error/GOV-WORKSPACE-LIFECYCLE.md": "72436684572d784327a340d90ca96d9fd2927519e02334036fab402e9c1d8262", + ".governance/error/GOV-WORK-START.md": "6dcec8198e98a23c34c1d34cf8fb14c25b973b7f25217a6cb0554959b6fbba3f", + ".governance/error/GOV-WORKSPACE-LIFECYCLE.md": "392b9f484ee26eff04a77d6c24d283413aa25d7e23e0d295ab3d1542f3e1ef3d", ".governance/error/GOV-WORKTREE-OVERLAP.md": "65a2533f13e63d6ebeeb63c07adc0794ea9e04075a91e873eff2d79910239b0e", ".governance/error/README.md": "e8486dd29f52ca3fee96ed6881a62c38141864cde5aa1adea2b16d22b2feefaa", ".governance/generate_required_checks.py": "a017f06203e3c186dd97bc3c3a183e0352f5f11b2f49701e61cac98e86c00cba", - ".governance/governance_check.py": "48a1cf1231031e6f1216ae556a4ca5434c1e3e775c246990e16d78708fe2176d", - ".governance/intent.schema.json": "7baa32440e3ae5bf1ada32359154b35038134ba2dee4dbf25d9435a12f62ab0c", + ".governance/governance_check.py": "024ca8b795e8d459131ba325d5edd66b5dd103740bc0c04f7c0de4accc971300", + ".governance/intent.schema.json": "c70b7f210c9f4f549870e2bda0f765e882b85cc75dd481250b36b7be8d3d2ef9", ".governance/lock.schema.json": "ad80c98f800a4a3310870336dcdaf0aa689cc4988f71084d25d76bea2df1242f", - ".governance/manifest.base.json": "00651e69bc4a67390ad58320883019541093973f79fbd96af2e8a6497a1868ce", + ".governance/manifest.base.json": "1c67c22baccf0b8fae5e2596db4d294e6f8232ba2f16c07523acf090c86042a2", ".governance/manifest.schema.json": "5aa2ccd3f6898834d4e39a78342448145490be56aa132e16ac7c9d64acef8f73", - ".governance/package-manifest.json": "fff95327f1bc0009e166a9731e308b1c64d46cf103f9de74910bddb51fdbcc17", - ".governance/precommit_standard_update.py": "7be31f9fe21b1e9de48841cd1d62af053fa2f358e5918e2805cfc3f1e3960c59", + ".governance/package-manifest.json": "fdaa684e66bf2d1b94c2b59839dd179a9d5468c42b89fc41f29c99727da4301b", + ".governance/precommit_standard_update.py": "c91e2bf9ae9d6ccc77bce0e61450c818a5961edee5bfde3b60426da88e296b0f", ".governance/remediation-intent.schema.json": "844f834775174b4c0e10f4530f5ac66f918a6f315428d3d70c884b688832d29e", ".governance/remediation-intent.template.dsl.json": "a3eb01c54fe678f3fcebb88103ac4eb02f5dd24016b2ba9552814b5e442dfb34", ".governance/remediation_intent.py": "8b056e89622ebf636384f6272f731e3c3677ca7e2b07088d5d51a15b202765fc", ".governance/required-checks.schema.json": "465f004d0e30f21e60e59c8b7860cc33db059e383e272e3caa267df7f24437f5", + ".governance/snapshot-migration.schema.json": "37e97ca683254a66d1a83576e3a4293e37663d38efb0cbc94e9deec1dd58b5ca", + ".governance/snapshot_migration.py": "fba93c1be632a7b1d47374fb6b31e228f969445de4207b8c2d6fb43af274b94e", ".governance/stack-profiles.json": "47a3b899553968dfc5e0565c0de525f13aadde5dacae4556614572a731054f0e", ".governance/standard-adoption.schema.json": "d9c58e86d11ebd23174ed8a5c209c13ae96bd4cd34b7866ac8c61a66de19bc9b", ".governance/standard-packs.json": "107f9fcc6231c108b216055c63ba43e8bd5ec152ea7a2a5906642c3484fceed9", @@ -62,30 +69,32 @@ ".governance/ticket-allocation-receipt.schema.json": "e3827eca95cdb833345964f0705a5aa8dc84c54cc369778bfb8b611419b191b2", ".governance/ticket-allocation-request.schema.json": "b3796ee4670bf78ab088f7a691edafe9630db2aa7a57663c12705ebc8c83df05", ".governance/ticket-allocation.schema.json": "bf05d64066f902a19f3d1d5359105c22b77c6538e27f29903f28c9b7a503fe13", - ".governance/ticket_activity.py": "b3aac41f53f1d6d9fcd9b309e3d6bd5c923b825d5ee34644aefbaa63ab3c85ad", + ".governance/ticket_activity.py": "c2672349d879d967634de3100377a6f0c45a116733a2edc52309d7a0577e7d7b", ".governance/ticket_allocation.py": "dac1d84caf462866b22df3c8e84952053f976d60285c930e3888f8fa3f11ddbe", ".governance/ticket_input.py": "b7fa667798e3a855f8c3504b78c652b549f15cd63a32b225e2ad67b9320f0d5e", - ".governance/ticket_storage.py": "1f8526fa7ce4c1b9207dd9a5ba5efb016d91475bae043ca406ed3df3ca5ab12d", + ".governance/ticket_storage.py": "399328bbced61d9b205a5a5257decb2f312d40f29280b3536bd4f593d478a26e", ".governance/work-classification.dsl.json": "3a947c41938c0b8ef1717957f313ff9248764252735182de30b1f2d6878748b6", ".governance/work-classification.schema.json": "f5c2b518238543589e4f8d3805cc6455e19d6919643644aaeae034abf472a467", ".governance/work-continuity.schema.json": "5134e6884ddaa4fb3a0ffd200d281afb0f3b868b06f71d65e3da6b42f6fe0830", + ".governance/work-start-report.schema.json": "af3a86ad2bd6e40c3c770ec2c879e1e78f3d37d70583b734ffd58d2a14b2e8d6", ".governance/work_continuity.py": "43402de7e0a899bdeb284dbf2535032b59517d697eafc057c38d03691afd5e92", + ".governance/work_start_check.py": "5d04ff79e51102dbc443b8f676b890a72c0220d38255577417f61ae5df98db50", ".governance/workspace_lifecycle_check.py": "9b800de09bf4c518f41f9c6d79c9145f488cdf2b9183eed1ccf68298fb33cc0e", ".governance/worktree_guard.py": "b154f6e67626770ec11c9544d31a27b32d9c215ef73ffc9eb8f52e9c3a9b051b", - ".governance/worktree_overlap_check.py": "4f89e3bbb4e23b6690e20038b4dbcf5db91fbcc0e191d90fb1aea424cf54ac99", + ".governance/worktree_overlap_check.py": "a7d17aa36344cbf644437e5f5d3b9dc4d8a264863bae2e80198b147f21d4b84d", ".governance/worktree_path_check.py": "fad10912f3b14913cc348880996b636ba0d31ea66a853dd264b53e4e66f17feb", ".governance/worktrees.lock.json": "5d42f7a7a1afc319dd8f670730f564271d886e22fe7ea61dd0d12911e7ce05ca", ".governance/worktrees.schema.json": "bb5989c19ee33d9beafa34576ef568ef70384a664ccf763ac2e29dde3a464756", ".subactor/.gitignore": "dd223aed5e053f94c6808ac434368c16eeca7e77218f8426e8cf5e46ae441d03", ".subactor/manifest.json": "ab8b1cbe4052a6f0005a3c33a43fa2a70e8837cd32c18b4183d0d448c3a8cee2", - "AGENTS.md": "6a2aef7bfa347c7471dc5dbdf9c1b928f42c372695910f0b34210e6634cd0df1", - "CLAUDE.md": "48c8159bd17f0a7ab05989ee06e3aeec0aab07f64347fcc271140d9f650d7713", - "GEMINI.md": "b66a7d12c4c877110eb263a80b32c065c4c2d581743d72cd6e06ebbe6dc842da", + "AGENTS.md": "77519c98acc5d4480d6829185857c88a4a0afe6b50e01d0f784e74c0fedaac26", + "CLAUDE.md": "628e743294cd4e23531eece8053595fec526080b20f2fac74248e43e5227442b", + "GEMINI.md": "f72a35f8a888b1727f4829fa33410e75a1e830a363cc7bf7c465e5c519535725", "project/governance-check.bat": "04f4fd3ba15abd6b874bde8fab0dd869402b84b9c83ae72da40c1a804b068045", "project/governance-check.sh": "8eb977ff01a96e47455d227ed5ced949eb53ea19537f870d9016f803840e048e", - "project/new-ticket.sh": "0ef6d471df436dddf2aecbe53bbb6a941f23fb998ce72ba0c611b8bfa6c3daed", + "project/new-ticket.sh": "1eb5784f229c8c68417788e1753f013c0ca3d1164e510cd5ee294bf326044dd7", "project/readme.sh": "b41a9c88374e6de0439284a4561fb11b1b482039bc5ba1bcf6683fd59b1a3968", - "scripts/install-agent-hosts.sh": "316a4dd6c877ba8f79a10bb761b188378deb8aea70fb7fcc54a729c664987f44", + "scripts/install-agent-hosts.sh": "c242127001e362a5748e096807de1a001ec9e4cca1d8e79d173769bc0b3804e6", "scripts/runtime.sh": "27ec7c0ff9ba3e16be5438ce2fd938a0e1dd34cc3a3627bf97155a83f4304306", "wellmanifest_governance.py": "d6b71f091ffd88fb30c96f54162020d9aeb6e54555328834b68ccb21868ecc07", "worktree-guard.yaml": "bea3d3cda9bd764f9e79b975da8f5360df894fdc04fca0407def88ebd49111b7" @@ -95,7 +104,7 @@ "id": "wellmanifest/new-project", "publicationStatus": "published", "sourceRepository": "wellmanifest/new-project", - "sourceRevision": "d54878a105a20d84dd554f205bc177dcacc8730a", - "version": "0.20.25" + "sourceRevision": "b6ba9c21a65a6a5648ecf904b64c3b75295e136f", + "version": "0.20.32" } } diff --git a/.governance/package-manifest.json b/.governance/package-manifest.json index a86ffb5..5c6aced 100644 --- a/.governance/package-manifest.json +++ b/.governance/package-manifest.json @@ -1,6 +1,24 @@ { "schema": "new-project.package-manifest/v1", "files": [ + { + "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", @@ -151,6 +169,12 @@ "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", @@ -570,6 +594,36 @@ "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": "error/GOV-ARCHITECTURE-001.md", + "target": ".governance/error/GOV-ARCHITECTURE-001.md", + "strategy": "managed", + "executable": false } ] } diff --git a/.governance/precommit_standard_update.py b/.governance/precommit_standard_update.py index 8769ae9..922b39d 100755 --- a/.governance/precommit_standard_update.py +++ b/.governance/precommit_standard_update.py @@ -4,7 +4,6 @@ from __future__ import annotations import argparse -import hashlib import json import shutil import subprocess @@ -13,7 +12,6 @@ DIAGNOSTIC = "GOV-STANDARD-UPDATE-001" -MANAGED_LOCK = ".governance/manifest.lock.json" DEFAULT_UPDATE_POLICY = { "enabled": True, "trigger": "pre-commit", @@ -32,76 +30,6 @@ def _refuse(message: str, *, returncode: int = 2) -> int: return returncode -def _staged_blobs(target: Path, paths: list[str]) -> dict[str, bytes] | None: - """Read the exact staged content of several paths in one Git process. - - A commit is judged on what is staged, never on the worktree. A repository - can carry close to a hundred managed files and this runs on every commit, - so the reads are batched instead of one ``git show`` per entry. - """ - request = "".join(f":{path}\n" for path in paths).encode("utf-8") - try: - completed = subprocess.run( - ["git", "-C", str(target), "cat-file", "--batch"], - input=request, capture_output=True, check=False, - ) - except OSError: - return None - if completed.returncode != 0: - return None - staged: dict[str, bytes] = {} - stream, offset = completed.stdout, 0 - for path in paths: - end = stream.find(b"\n", offset) - if end < 0: - return None - header = stream[offset:end].decode("utf-8", "replace").split() - if len(header) != 3 or not header[2].isdigit(): - # " missing": the path is absent from the index entirely. - offset = end + 1 - continue - size = int(header[2]) - staged[path] = stream[end + 1:end + 1 + size] - offset = end + 1 + size + 1 - return staged - - -def staged_managed_drift(target: Path) -> tuple[str, ...] | None: - """Report which standard-managed files this commit would leave inconsistent. - - Staleness and drift are different facts. Staleness means the world moved: - a newer standard revision was published. Drift means this repository moved: - a managed file no longer matches the digest recorded in its own pinned - lock. Only drift is this repository's doing, and only drift is something a - commit can cause or repair. - - Returns the drifted paths, an empty tuple when the staged tree is - internally consistent with its own pin, or ``None`` when the answer cannot - be established — and an unestablished answer never relaxes anything. - """ - lock = _staged_blobs(target, [MANAGED_LOCK]) - if not lock or MANAGED_LOCK not in lock: - return None - try: - managed = json.loads(lock[MANAGED_LOCK].decode("utf-8"))["managedFiles"] - except (KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError): - return None - if not isinstance(managed, dict) or not managed: - return None - paths = sorted(managed) - staged = _staged_blobs(target, paths) - if staged is None: - return None - drifted = [] - for path in paths: - digest, content = managed[path], staged.get(path) - if content is None or not isinstance(digest, str): - drifted.append(path) - elif hashlib.sha256(content).hexdigest() != digest: - drifted.append(path) - return tuple(drifted) - - def _load_update_policy(path: Path) -> dict[str, object]: try: adoption = json.loads(path.read_text(encoding="utf-8")) @@ -187,14 +115,6 @@ def run( file=sys.stderr, ) if completed.returncode != 0: - if _staleness_only(completed, target): - print( - f"{DIAGNOSTIC}: the pinned standard is behind the published " - "revision, but no managed file drifted in this commit; " - "adopt the new revision in a governance ticket.", - file=sys.stderr, - ) - return 0 return _refuse( "Goal refused or prepared a standard update; review its evidence before retrying", returncode=completed.returncode, @@ -202,30 +122,6 @@ def run( return 0 -def _staleness_only( - completed: subprocess.CompletedProcess[str], target: Path, -) -> bool: - """Decide whether Goal's refusal is staleness that this commit cannot fix. - - Goal refuses a stale pin unless the committing ticket is itself a - governance adoption ticket binding the exact old and new revisions. An - implementation ticket can never be that, so on a standard that publishes - several revisions a day the gate stops every unrelated commit in the - repository for a reason none of those commits caused. Measured on - 2026-09-08: seven published revisions in one day, and five pull-request - repairs in one adopter blocked for a full day with zero drift. - - Both conditions must hold before the commit proceeds: Goal's own stable - diagnostic identifies the refusal as adoption authorization, and the staged - tree still matches every digest its pinned lock records. Any other refusal - keeps the commit closed, and so does drift or evidence that cannot be read - at all — an unestablished answer never relaxes the gate. - """ - if DIAGNOSTIC not in f"{completed.stderr or ''}\n{completed.stdout or ''}": - return False - return staged_managed_drift(target) == () - - def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, required=True) 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_migration.py b/.governance/snapshot_migration.py new file mode 100755 index 0000000..e71aa89 --- /dev/null +++ b/.governance/snapshot_migration.py @@ -0,0 +1,256 @@ +#!/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 + metadata, name = raw.split(b'\t', 1) + mode, kind, oid = metadata.decode('ascii').split() + path = name.decode('utf-8') + 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') + 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 workspace_entry(root, path): + """Hash only the approved path, never following candidate directory symlinks.""" + 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 + info = os.stat(parts[-1], dir_fd=descriptor, follow_symlinks=False) + if stat.S_ISLNK(info.st_mode): + raw = os.fsencode(os.readlink(parts[-1], dir_fd=descriptor)) + mode = '120000' + elif stat.S_ISREG(info.st_mode): + with os.fdopen(os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=descriptor), 'rb') as stream: + raw = stream.read() + mode = '100755' if info.st_mode & stat.S_IXUSR else '100644' + else: + return {'unsupported': True} + 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/ticket_activity.py b/.governance/ticket_activity.py index 6c20815..d537c06 100755 --- a/.governance/ticket_activity.py +++ b/.governance/ticket_activity.py @@ -10,6 +10,7 @@ import subprocess import sys import tempfile +from contextvars import ContextVar from dataclasses import asdict, dataclass from pathlib import Path from typing import Any @@ -47,7 +48,107 @@ class ActivityResolution: 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, @@ -60,14 +161,14 @@ def _git(root: Path, *args: str, check: bool = True) -> str: def _load(path: Path) -> Any: try: - return json.loads(path.read_text(encoding="utf-8")) + 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 candidate.is_file(): + if _file(candidate, "is_file"): return candidate raise ActivityPolicyMissing("managed ticket activity policy is missing") @@ -77,7 +178,7 @@ def override_path(root: Path) -> Path | None: root / ".governance/ticket-activity.override.json", root / "governance/ticket-activity.override.json", ): - if candidate.is_file(): + if _file(candidate, "is_file"): return candidate return None @@ -157,7 +258,7 @@ def repository_ref(root: Path) -> str: def projection_status(ticket_dir: Path) -> str | None: try: - text = (ticket_dir / "README.md").read_text(encoding="utf-8") + text = _read_text(ticket_dir / "README.md") except OSError: return None match = re.search(r"(?mi)^-[ \t]+\*\*Status\*\*:[ \t]*([A-Z_]+)[ \t]*$", text) @@ -348,8 +449,9 @@ def _terminal_verified( def _target_ref(root: Path, branch: str) -> str | None: for ref in (f"refs/remotes/origin/{branch}", f"refs/heads/{branch}"): - if _git(root, "rev-parse", "--verify", ref, check=False): - return ref + sha = _git(root, "rev-parse", "--verify", ref, check=False) + if sha: + return sha return None @@ -366,8 +468,10 @@ def _unmerged_ticket_branch(root: Path, ticket: str, target: str) -> bool: """Report whether any branch for this ticket is still outside the target.""" number = ticket.removeprefix("ticket-") listed = _git( - root, "for-each-ref", "--format=%(refname)", + root, "for-each-ref", "--format=%(objectname)", + f"refs/remotes/origin/ticket/{number}", f"refs/remotes/origin/ticket/{number}-*", + f"refs/heads/ticket/{number}", f"refs/heads/ticket/{number}-*", check=False, ) @@ -427,7 +531,7 @@ def resolve(root: Path, ticket_dir: Path, active_statuses: set[str], *, status_o 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 path.exists(): + 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", @@ -451,6 +555,8 @@ def resolve(root: Path, ticket_dir: Path, active_statuses: set[str], *, status_o 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] diff --git a/.governance/ticket_storage.py b/.governance/ticket_storage.py index c593725..a707f21 100644 --- a/.governance/ticket_storage.py +++ b/.governance/ticket_storage.py @@ -52,18 +52,51 @@ def invoke(root, pin, *args, content=None): return json.loads(result.stdout) +def scoped_paths(root, workstream, paths): + """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)) + owned = patterns(manifest['coordination']['workstreams'][workstream]['ownedPaths']) + if not material(scope) or any(not any(pattern_covered_by(path, owner) for owner in owned) for path in scope): + raise ValueError("nonempty implementation scope owned by the workstream required") + return scope + + +def persist_scope(args): + scope = scoped_paths(args.root, args.workstream, args.path) + 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 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}, - # Allocation reserves identity, not source scope. The caller fills its - # bounded implementation intent in SQLite before changing source. - "allowedPaths": [f"project/{ticket}/**"], "forbiddenPaths": ["project/ticket-*/user-*.md"], + # 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") @@ -76,16 +109,19 @@ def create(args): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=["digest", "verify", "highest", "create", "active"]) + parser.add_argument("command", choices=["digest", "verify", "highest", "create", "active", "scope"]) 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=[]) for name in ("ticket", "title", "workstream", "kind", "priority", "origin", "allocation-key"): parser.add_argument("--" + name) args = parser.parse_args() try: - if args.command == "active": + if args.command == "scope": + print(json.dumps(persist_scope(args))) + elif args.command == "active": from ticket_activity import resolve text = read_file(args.root, args.ticket, "README.md").decode("utf-8") match = re.search(r"(?mi)^-[ \t]+\*\*Status\*\*:[ \t]*([A-Z_]+)[ \t]*$", text) @@ -105,6 +141,8 @@ def main(): print(json.dumps(create(args))) except Exception: # Do not echo command input, ticket contents or child stderr. + 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") diff --git a/.governance/work-start-report.schema.json b/.governance/work-start-report.schema.json new file mode 100644 index 0000000..022f3b3 --- /dev/null +++ b/.governance/work-start-report.schema.json @@ -0,0 +1,389 @@ +{ + "$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" + } + } + }, + { + "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": { + "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_start_check.py b/.governance/work_start_check.py new file mode 100755 index 0000000..4ec6f2b --- /dev/null +++ b/.governance/work_start_check.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +"""Read-only, clone-local work admission. Recommendations never grant authority.""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys + +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 + + +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 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 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" + return material(sorted(paths)), digest({"status": status, "files": hashes}), sorted(paths) + + +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() + + +def inspect(root, workstream, requested_paths=(), ticket=None, storage=None, + observe_publication=False, expected_dirty_digest=None): + root = Path(git(root, "rev-parse", "--show-toplevel").strip()).resolve() + manifest = manifest_at(root) + coordination = manifest["coordination"] + stream = coordination["workstreams"][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") + 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) + 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" + pending = bool(all_dirty or ahead or ticket_id in records) + 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] + else: + intent = read_json(ticket_dir / "intent.json") + status_override = None + 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: + intent = read_json(path / "project" / key / "intent.json") + resolution = resolve_activity(path, path / "project" / key, statuses) + if resolution.projectionStatus is None: + raise ObservationError("Unassigned ticket status unavailable") + 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"]) + contested = intersects(requested, entry["dirtyPaths"] + 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": ""} + 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) + 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 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("--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("--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") + try: + payload = inspect(args.root, args.workstream, args.path, args.ticket, args.storage, + args.observe_publication, args.expect_dirty_digest) + except (ObservationError, ActivityError, KeyError, TypeError, ValueError, OSError, StopIteration): + # No exception content: remote URLs or secret-bearing input never leak. + print(json.dumps({"schema": SCHEMA, "readOnly": True, "grantsAuthority": False, + "createsWorktree": False, "route": "RECONCILE", "diagnostic": CODE, + "reason": "Observation incomplete or inconsistent; preserve work and reconcile."})) + return 3 + if args.allocation_check and payload["route"] != "NEW_TICKET_CANDIDATE": + payload["diagnostic"] = CODE + 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/worktree_overlap_check.py b/.governance/worktree_overlap_check.py index a860f8f..de3b50a 100755 --- a/.governance/worktree_overlap_check.py +++ b/.governance/worktree_overlap_check.py @@ -27,7 +27,7 @@ sys.dont_write_bytecode = True try: try: - from ticket_activity import ActivityError + 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( @@ -39,6 +39,7 @@ 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 @@ -498,6 +499,12 @@ def changes_against_shared_default(first, second, first_dirty, second_dirty, fir # 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 @@ -514,6 +521,8 @@ def contested_paths( 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) @@ -668,20 +677,24 @@ def ticket_scopes(root: Path) -> tuple[tuple[TicketScope, ...], tuple[str, ...]] statuses = active_statuses(root) if not statuses: return (), () - 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 - intent = scope_intent(directory, virtual) - scopes.append(ticket_scope_record(directory, intent)) + 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 + intent = scope_intent(directory, virtual) + scopes.append(ticket_scope_record(directory, intent)) + except ActivityError as error: + errors.append(str(error)) return tuple(scopes), tuple(errors) diff --git a/AGENTS.md b/AGENTS.md index c17d9a8..9c6f9ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,30 @@ # 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) +- 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) +- 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) +- 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 @@ -28,6 +53,14 @@ SERVICE/FEATURE that create a repo, fill `intent.json` `placement` 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. + 1. Read `.governance/manifest.json`, `TODO.md`, `project/TICKETS.md` and the active ticket. Respect `repository.mode`: `standalone` owns a separate repository, while @@ -75,9 +108,16 @@ Before any multi-step implementation, an agent must: `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. Serialize ticket-ID allocation before branching, then use a separate - branch/worktree per implementation ticket. Resolve its location with the - managed `wellmanifest/worktrees` checker. Resolve the primary checkout from +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 @@ -156,8 +196,10 @@ Before any multi-step implementation, an agent must: 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` after - fetching/pruning. Never create or copy `project/ticket-{NNN}` manually; the +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 @@ -209,6 +251,26 @@ Before any multi-step implementation, an agent must: 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. + +## 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. diff --git a/CLAUDE.md b/CLAUDE.md index 924417d..d3946b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,20 @@ # 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 @@ -19,3 +34,7 @@ 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 index f797201..e700b4c 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,5 +1,20 @@ # 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 @@ -19,3 +34,7 @@ Fail-closed. Do not write code until this contract is followed. 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/project/TICKETS.md b/project/TICKETS.md index ab8256e..80cfa26 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -110,6 +110,9 @@ This file indexes governance tickets without taking ownership of | **ticket-110** | [`README.md`](./ticket-110/README.md) | - | - | - | - | - | | **ticket-111** | [`README.md`](./ticket-111/README.md) | - | - | [`ai-codex.md`](./ticket-111/ai-codex.md) | - | - | | **ticket-112** | [`README.md`](./ticket-112/README.md) | - | - | [`ai-codex.md`](./ticket-112/ai-codex.md) | - | - | +| **ticket-113** | [`README.md`](./ticket-113/README.md) | - | - | - | - | - | +| **ticket-114** | [`README.md`](./ticket-114/README.md) | - | - | - | - | - | +| **ticket-167** | [`README.md`](./ticket-167/README.md) | - | - | - | - | - | | **ticket-168** | [`README.md`](./ticket-168/README.md) | - | - | - | - | - | | **ticket-169** | [`README.md`](./ticket-169/README.md) | - | - | - | - | - | diff --git a/project/new-ticket.sh b/project/new-ticket.sh index 203e62f..0436533 100755 --- a/project/new-ticket.sh +++ b/project/new-ticket.sh @@ -7,6 +7,7 @@ TITLE="New Task Ticket" USERS="" AGENT="antigravity" WORKSTREAM="" +SCOPE_ARGUMENTS=() FORCE_NEW=false ALLOCATION_KEY="" ALLOCATION_RECEIPT="" @@ -30,6 +31,7 @@ Usage: ./project/new-ticket.sh [options] -t, --title TITLE Ticket title -a, --agent ID Agent provider/id used for ai-{ID}.md -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 @@ -53,6 +55,7 @@ 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 } @@ -86,6 +89,11 @@ while [[ $# -gt 0 ]]; do WORKSTREAM="$2" shift 2 ;; + --path) + require_value "$@" + SCOPE_ARGUMENTS+=("--path=$2") + shift 2 + ;; -k|--kind) require_value "$@" KIND="$2" @@ -305,6 +313,16 @@ require_classification_value kind "$KIND" require_classification_value priority "$PRIORITY" require_classification_value origin "$ORIGIN" +# Validate the explicit scope before any identity reservation or registered +# allocation request. The same argv is used for admission and both stores. +if (( ${#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 + python3 "$TICKET_STORAGE_HELPER" scope --root "$PWD" --workstream "$WORKSTREAM" "${SCOPE_ARGUMENTS[@]}" >/dev/null +fi + allocation_config() { local candidate for candidate in .governance/ticket-allocation.json governance/ticket-allocation.json; do @@ -368,8 +386,7 @@ if git_common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/de trap release_allocation_lock EXIT INT TERM fi -# The allocator owns the freshness requirement. Relying on a caller to fetch -# recreates the same partial view that clone-wide locking is meant to avoid. +# 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 @@ -380,6 +397,30 @@ if [[ "$REFRESH_REMOTE" == true ]] \ 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 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 + if ! start_report="$(python3 "$start_runtime" --root . --workstream "$WORKSTREAM" --storage "$TICKET_STORAGE" "${SCOPE_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() { @@ -504,7 +545,7 @@ if [[ "$TICKET_STORAGE" == sqlite ]]; then 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" + --runtime-root "$STORE_ROOT" --runtime-sha256 "$STORE_SHA256" "${SCOPE_ARGUMENTS[@]}" exit 0 fi @@ -617,6 +658,11 @@ else EOF fi +if (( ${#SCOPE_ARGUMENTS[@]} )); then + python3 "$TICKET_STORAGE_HELPER" scope --root "$PWD" --workstream "$WORKSTREAM" \ + --ticket "$ticket_id" "${SCOPE_ARGUMENTS[@]}" >/dev/null +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 diff --git a/project/ticket-170/README.md b/project/ticket-170/README.md new file mode 100644 index 0000000..90189c9 --- /dev/null +++ b/project/ticket-170/README.md @@ -0,0 +1,30 @@ +# Ticket 170: Adopt wellmanifest standard 0.20.32 + +- **ID**: ticket-170 +- **Owner**: codex-fleet-goal170-handoff-20260919 +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-09-19 + +## Goal and scope + +Adopt the verified wellmanifest/new-project 0.20.32 managed package atomically, +including its packaging binding. Preserve the documentation adoption already +merged in PR #170 and validate against main at 969357f9d84402c6fd34c422571a7992886e08bb. + +SESSION_EXECUTION_AUTHORIZATION: the user requested autonomous delivery and +explicitly confirmed the previous owner's completion and handoff of tickets +170, 171 and 172 on 2026-09-19. Protected exact-head review still owns merge. + +## Acceptance criteria + +- [x] AC-01: Managed package and packaging binding pass governance validation on the current accepted base. +- [x] AC-02: Existing Python regression suite passes (899 passed, 2 skipped). +- [ ] AC-03: Protected CI and independent Validator accept the published exact head. + +## Reconciliation + +The helper ticket 171 packaging delta is identical to this adoption's binding. +Ticket 172's separate integration-ownership policy proposal remains preserved +for later evaluation; it is not needed by the standard's atomic adoption rule. +External recovery receipts retain both helpers' uncommitted material. diff --git a/project/ticket-170/intent.json b/project/ticket-170/intent.json new file mode 100644 index 0000000..40c3098 --- /dev/null +++ b/project/ticket-170/intent.json @@ -0,0 +1,108 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-170", + "summary": "Adopt wellmanifest/new-project 0.20.32 managed package refresh", + "workstream": "governance", + "classification": { + "kind": "SERVICE", + "priority": "P2", + "origin": "health" + }, + "allowedPaths": [ + "project/ticket-170/**", + "TODO.md", + "project/TICKETS.md", + ".aider.conf.yml", + ".cursor/rules/new-project-standard.mdc", + ".github/copilot-instructions.md", + ".githooks/pre-commit", + ".governance/**", + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + "project/new-ticket.sh", + "scripts/install-agent-hosts.sh", + "scripts/runtime.sh", + "worktree-guard.yaml", + ".gitignore", + ".subactor/manifest.json", + ".subactor/.gitignore", + "pyproject.toml" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "969357f9d84402c6fd34c422571a7992886e08bb", + "targetBranch": "main", + "outcome": "Adopt the verified wellmanifest/new-project 0.20.32 package and keep managed host/governance files hash-bound.", + "nonGoals": [ + "No hand-edited managed files or lock digests", + "No product runtime behavior changes", + "No edits in other repositories" + ], + "complexity": "L", + "estimatedMinutes": 90, + "standardAdoption": { + "sourceRepository": "wellmanifest/new-project", + "fromRevision": "d54878a105a20d84dd554f205bc177dcacc8730a", + "toRevision": "b6ba9c21a65a6a5648ecf904b64c3b75295e136f" + }, + "budgets": { + "maxImplementationFiles": 15, + "maxAffectedComponents": 3, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Use verified standard adoption process to replace the complete managed package and regenerate its exact lock from the published revision.", + "components": [ + { + "name": "managed-governance-package", + "paths": [ + ".governance/**", + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + ".aider.conf.yml", + ".cursor/rules/new-project-standard.mdc", + ".github/copilot-instructions.md", + ".githooks/pre-commit", + "project/new-ticket.sh", + "scripts/install-agent-hosts.sh", + "scripts/runtime.sh", + "worktree-guard.yaml", + ".gitignore", + ".subactor/manifest.json", + ".subactor/.gitignore", + "pyproject.toml" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the atomic managed package adoption to the previous accepted revision." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "./project/governance-check.sh" + ], + "evidence": "Managed adoption verified up-to-date and local governance gate passes." + } + ] + } +} diff --git a/pyproject.toml b/pyproject.toml index fc0477a..f12120c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,8 +70,8 @@ python_files = ["test_*.py"] addopts = "-p wellmanifest_governance" [tool.wellmanifest] -standard = "0.20.25" -revision = "d54878a105a20d84dd554f205bc177dcacc8730a" +standard = "0.20.32" +revision = "b6ba9c21a65a6a5648ecf904b64c3b75295e136f" gate = "project/governance-check.sh" [tool.tox] diff --git a/scripts/install-agent-hosts.sh b/scripts/install-agent-hosts.sh index 80631d9..0108774 100755 --- a/scripts/install-agent-hosts.sh +++ b/scripts/install-agent-hosts.sh @@ -228,17 +228,43 @@ install_user_files() { local marker="wellmanifest/new-project host contract" for pointer in "$task_user_home/.gemini/GEMINI.md" "$task_user_home/.claude/CLAUDE.md"; do - if [[ ! -f "$pointer" ]] || ! grep -Fq "$marker" "$pointer"; then - cat >> "$pointer" <