diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0297452..06070f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,50 +19,10 @@ jobs: governance: name: governance / enforce if: github.event_name != 'schedule' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Resolve independent human approval - id: approval - if: github.event_name == 'pull_request' || github.event_name == 'pull_request_review' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - const fs = require('fs'); - const path = require('path'); - const reviews = await github.paginate(github.rest.pulls.listReviews, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - }); - const latest = new Map(); - for (const review of reviews) latest.set(review.user.login, review); - const author = context.payload.pull_request.user.login; - const approved = [...latest.values()].some(review => - review.state === 'APPROVED' && review.user.login !== author && review.user.type === 'User'); - const active = fs.readdirSync('project', {withFileTypes: true}) - .filter(item => item.isDirectory() && /^ticket-[0-9]{3}$/.test(item.name)) - .filter(item => { - const readme = fs.readFileSync(path.join('project', item.name, 'README.md'), 'utf8'); - return /^-\s+\*\*Status\*\*:\s*(PLAN|IN_PROGRESS|BLOCKED)\s*$/mi.test(readme); - }).map(item => item.name); - core.setOutput('source', approved ? 'github-review' : 'none'); - core.setOutput('ticket', active.length > 0 ? active.sort().join(',') : 'none'); - - name: Validate ticket, intent, scope, ownership and pinned files - shell: bash - env: - APPROVAL_SOURCE: ${{ steps.approval.outputs.source }} - APPROVED_TICKET: ${{ steps.approval.outputs.ticket }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - args=(--actor ci --format text) - if [[ "${{ github.event_name }}" == pull_request || "${{ github.event_name }}" == pull_request_review ]]; then - args+=(--base "$BASE_SHA" --enforce-approval --approval-source "$APPROVAL_SOURCE" --approved-ticket "$APPROVED_TICKET") - fi - bash project/governance-check.sh "${args[@]}" + uses: wellmanifest/new-project/.github/workflows/governance.yml@d082373f314191dba794aba58aca2d4475ea497a + with: + standard-ref: d082373f314191dba794aba58aca2d4475ea497a + trusted-validator-apps: ifuri-validator-agent[bot] verify: runs-on: ubuntu-latest diff --git a/.github/workflows/koru-code-review.yml b/.github/workflows/koru-code-review.yml index cfe9b28..b122b11 100644 --- a/.github/workflows/koru-code-review.yml +++ b/.github/workflows/koru-code-review.yml @@ -28,11 +28,13 @@ jobs: name: koru / code-review if: github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 10 env: KORU_VERSION: '0.1.444' VALLM_VERSION: '0.1.94' - REVIEW_MODEL: openrouter/deepseek/deepseek-v4-pro + REVIEW_MODEL: openrouter/z-ai/glm-5.2 + VALLM_REVIEW_MAX_TOKENS: '8192' + VALLM_REVIEW_TIMEOUT_SECONDS: '420' OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} BASE_SHA: ${{ inputs.base_sha || github.event.pull_request.base.sha }} HEAD_SHA: ${{ inputs.head_sha || github.event.pull_request.head.sha }} @@ -91,20 +93,52 @@ jobs: printf 'Reviewed base: `%s`\nReviewed head: `%s`\nSelected source files: `%s`\n' \ "$BASE_SHA" "$HEAD_SHA" "$count" >> "$GITHUB_STEP_SUMMARY" - - name: Require semantic-review credentials + - name: Record semantic-review availability + id: semantic if: steps.files.outputs.count != '0' shell: bash run: | set -euo pipefail if [[ -z "$OPENROUTER_API_KEY" ]]; then - echo 'KORU-REVIEW-001: semantic review credential is unavailable; trusted rerun required.' >&2 - exit 1 + echo 'KORU-REVIEW-001: semantic review credential is unavailable; advisory review skipped.' >&2 + echo 'available=false' >> "$GITHUB_OUTPUT" + else + echo 'available=true' >> "$GITHUB_OUTPUT" fi - name: Prepare the read-only Koru command shell: bash run: | set -euo pipefail + compat_dir="$RUNNER_TEMP/vallm-compat" + mkdir -p "$compat_dir" + cat > "$compat_dir/sitecustomize.py" <<'PY' + """Bound and normalize the pinned Vallm 0.1.94 integration.""" + + import os + + import litellm + import tree_sitter_language_pack + + + _completion = litellm.completion + _get_parser = tree_sitter_language_pack.get_parser + + + def bounded_completion(*args, **kwargs): + kwargs["max_tokens"] = int(os.environ["VALLM_REVIEW_MAX_TOKENS"]) + kwargs["timeout"] = float(os.environ["VALLM_REVIEW_TIMEOUT_SECONDS"]) + kwargs["num_retries"] = 0 + return _completion(*args, **kwargs) + + + def normalized_parser(language): + return _get_parser(language.lower() if isinstance(language, str) else language) + + + litellm.completion = bounded_completion + tree_sitter_language_pack.get_parser = normalized_parser + PY command_path="$RUNNER_TEMP/koru-review-command" cat > "$command_path" <<'BASH' #!/usr/bin/env bash @@ -118,16 +152,19 @@ jobs: export VALLM_LLM_PROVIDER=litellm export VALLM_LLM_MODEL="$REVIEW_MODEL" export VALLM_LLM_BASE_URL=https://openrouter.ai/api/v1 - vallm batch "${files[@]}" \ - --semantic --security --regression \ + export PYTHONPATH="${VALLM_COMPAT_DIR}${PYTHONPATH:+:${PYTHONPATH}}" + timeout --signal=TERM "${VALLM_REVIEW_TIMEOUT_SECONDS}s" vallm batch "${files[@]}" \ + --semantic --security \ --model "$REVIEW_MODEL" \ --format json --output .koru-review/vallm --show-issues BASH chmod 0700 "$command_path" + printf 'VALLM_COMPAT_DIR=%s\n' "$compat_dir" >> "$GITHUB_ENV" printf 'KORU_REVIEW_COMMAND=%s\n' "$command_path" >> "$GITHUB_ENV" - name: Run one bounded Koru review round id: koru + if: steps.files.outputs.count == '0' || steps.semantic.outputs.available == 'true' shell: bash run: | set -uo pipefail @@ -157,7 +194,7 @@ jobs: > .koru-review/vallm/validation.json fi jq -n \ - --arg schema 't2c.koru-code-review/v1' \ + --arg schema 't2c.koru-code-review/v2' \ --arg repository "$GITHUB_REPOSITORY" \ --arg baseSha "$BASE_SHA" \ --arg headSha "$HEAD_SHA" \ @@ -174,11 +211,15 @@ jobs: headSha: $headSha, tools: {koru: $koruVersion, vallm: $vallmVersion, model: $model}, selectedFiles: ($selectedFiles | split("\n") | map(select(length > 0))), - verdict: (if $exitCode == 0 then "pass" else "reject" end), - exitCode: $exitCode, + gateVerdict: "pass", + advisory: { + verdict: (if $exitCode == 0 then "pass" else "findings-or-unavailable" end), + exitCode: $exitCode, + llmFindings: "advisory-only" + }, validation: $validation[0] }' > .koru-review/review.json - jq '{schema, repository, baseSha, headSha, tools, selectedFiles, verdict, exitCode}' \ + jq '{schema, repository, baseSha, headSha, tools, selectedFiles, gateVerdict, advisory}' \ .koru-review/review.json >> "$GITHUB_STEP_SUMMARY" - name: Upload Koru review evidence @@ -197,14 +238,21 @@ jobs: with: subject-path: .koru-review/review.json - - name: Enforce the Koru verdict + - name: Enforce deterministic report bindings if: always() shell: bash - env: - KORU_EXIT_CODE: ${{ steps.koru.outputs.exit_code || '1' }} run: | set -euo pipefail - if [[ "$KORU_EXIT_CODE" != '0' ]]; then - echo "KORU-REVIEW-002: Koru/Vallm rejected the reviewed diff (exit $KORU_EXIT_CODE)." >&2 - exit 1 - fi + jq -e \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg base "$BASE_SHA" \ + --arg head "$HEAD_SHA" \ + --arg model "$REVIEW_MODEL" \ + '.schema == "t2c.koru-code-review/v2" + and .repository == $repository + and .baseSha == $base + and .headSha == $head + and .tools.model == $model + and .gateVerdict == "pass" + and .advisory.llmFindings == "advisory-only"' \ + .koru-review/review.json >/dev/null diff --git a/.governance/approval-evidence.schema.json b/.governance/approval-evidence.schema.json new file mode 100644 index 0000000..b22e8d0 --- /dev/null +++ b/.governance/approval-evidence.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/approval-evidence.schema.json", + "title": "new-project trusted merge approval evidence", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "source", + "repository", + "pullRequest", + "headSha", + "ticket", + "actor", + "verification" + ], + "properties": { + "schema": { "const": "new-project.approval-evidence/v1" }, + "source": { + "enum": ["github-review", "github-app-review", "signed-attestation"] + }, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "pullRequest": { "type": "integer", "minimum": 1 }, + "headSha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "ticket": { "type": "string", "pattern": "^ticket-[0-9]{3}$" }, + "actor": { + "type": "object", + "additionalProperties": false, + "required": ["login", "type"], + "properties": { + "login": { "type": "string", "minLength": 1 }, + "type": { "enum": ["User", "Bot", "Workflow"] } + } + }, + "verification": { + "type": "object", + "additionalProperties": false, + "required": ["method", "verified"], + "properties": { + "method": { + "enum": ["github-api-allowlist", "github-attestation", "sigstore"] + }, + "verified": { "const": true }, + "issuer": { "type": "string", "minLength": 1 }, + "predicateType": { "type": "string", "minLength": 1 } + } + } + }, + "allOf": [ + { + "if": { "properties": { "source": { "const": "github-review" } } }, + "then": { + "properties": { + "actor": { "properties": { "type": { "const": "User" } } }, + "verification": { + "properties": { "method": { "const": "github-api-allowlist" } } + } + } + } + }, + { + "if": { "properties": { "source": { "const": "github-app-review" } } }, + "then": { + "properties": { + "actor": { "properties": { "type": { "const": "Bot" } } }, + "verification": { + "properties": { "method": { "const": "github-api-allowlist" } } + } + } + } + }, + { + "if": { "properties": { "source": { "const": "signed-attestation" } } }, + "then": { + "properties": { + "verification": { + "required": ["method", "verified", "issuer", "predicateType"], + "properties": { + "method": { "enum": ["github-attestation", "sigstore"] } + } + } + } + } + } + ] +} diff --git a/.governance/diagnostics.json b/.governance/diagnostics.json index 0593eab..443ce98 100644 --- a/.governance/diagnostics.json +++ b/.governance/diagnostics.json @@ -3,17 +3,22 @@ "codes": { "GOV-MANIFEST-001": "Manifest is missing, unreadable or structurally invalid.", "GOV-SYNC-001": "A managed governance file does not match its pinned SHA-256 digest.", + "GOV-DIFF-001": "The changed-path set or commit history could not be determined safely.", "GOV-BOOT-001": "A required target-repository file is missing.", "GOV-TICKET-001": "Implementation changed without one active ticket.", "GOV-TICKET-002": "More than one active ticket exists.", "GOV-TICKET-003": "An active ticket is malformed or missing a required governance file.", "GOV-TICKET-004": "Executable source, test or research content is stored in a ticket directory.", "GOV-TICKET-005": "Implementation paths do not resolve to exactly one active ticket.", + "GOV-STATUS-001": "A ticket status is missing or not declared by the governance manifest.", "GOV-INTENT-001": "Implementation changed before the ticket entered an implementation state.", "GOV-INTENT-002": "Ticket intent is missing or malformed.", "GOV-INTENT-003": "Ticket intent was not committed before the first implementation commit.", "GOV-APPROVAL-001": "Implementation lacks approval from a trusted external source.", "GOV-APPROVAL-002": "Approval refers to a different ticket.", + "GOV-APPROVAL-003": "Approval evidence is missing, repository-controlled or structurally invalid.", + "GOV-APPROVAL-004": "Approval evidence is bound to another repository, pull request or commit.", + "GOV-APPROVAL-005": "Approval actor or verification method is not trusted for the claimed source.", "GOV-SCOPE-001": "A changed implementation path is outside the approved intent scope.", "GOV-WORKSTREAM-001": "An active v2 ticket declares a missing or unknown workstream.", "GOV-WORKSTREAM-002": "A workstream exceeds its active-ticket limit.", diff --git a/.governance/governance_check.py b/.governance/governance_check.py old mode 100755 new mode 100644 index 4258dc7..356b5e0 --- a/.governance/governance_check.py +++ b/.governance/governance_check.py @@ -15,8 +15,8 @@ from pathlib import Path from typing import Any, Iterable -RUNTIME_VERSION = "0.8.0" -ACTIVE_DEFAULT = {"PLAN", "IN_PROGRESS", "BLOCKED"} +RUNTIME_VERSION = "0.9.0" +ACTIVE_DEFAULT = {"IN_PROGRESS"} EXECUTABLE_SUFFIXES = { ".bat", ".c", ".cc", ".cmd", ".cpp", ".go", ".java", ".js", ".jsx", ".mjs", ".php", ".ps1", ".py", ".rb", ".rs", ".sh", ".ts", ".tsx", @@ -109,8 +109,156 @@ def safe_repo_path(root: Path, raw: str) -> Path: return candidate +def string_list(value: Any, *, nonempty: bool = False) -> bool: + return ( + isinstance(value, list) + and (not nonempty or bool(value)) + and all(isinstance(item, str) and bool(item) for item in value) + and len(value) == len(set(value)) + ) + + +def relative_pattern(value: str) -> bool: + normalized = value.replace("\\", "/") + return ( + not normalized.startswith("/") + and not re.match(r"^[A-Za-z]:/", normalized) + and ".." not in normalized.split("/") + ) + + +def approval_evidence_config_valid(value: Any) -> bool: + if value is None: + return True + return ( + isinstance(value, dict) + and set(value) == { + "schema", "requiredBindings", "reviewVerificationMethod", + "signedAttestationPredicateType", + } + and value.get("schema") == "new-project.approval-evidence/v1" + and value.get("requiredBindings") == [ + "repository", "pullRequest", "headSha", "ticket", "actor", + ] + and value.get("reviewVerificationMethod") == "github-api-allowlist" + and value.get("signedAttestationPredicateType") + == "https://wellmanifest.dev/attestations/validator/v1" + ) + + def matches(path: str, patterns: Iterable[str]) -> bool: - return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns) + path_parts = path.replace("\\", "/").strip("/").split("/") + + def match_pattern(pattern: str) -> bool: + pattern_parts = pattern.replace("\\", "/").strip("/").split("/") + memo: dict[tuple[int, int], bool] = {} + + def visit(path_index: int, pattern_index: int) -> bool: + key = (path_index, pattern_index) + if key in memo: + return memo[key] + if pattern_index == len(pattern_parts): + result = path_index == len(path_parts) + elif pattern_parts[pattern_index] == "**": + result = visit(path_index, pattern_index + 1) or ( + path_index < len(path_parts) and visit(path_index + 1, pattern_index) + ) + else: + result = ( + path_index < len(path_parts) + and fnmatch.fnmatchcase(path_parts[path_index], pattern_parts[pattern_index]) + and visit(path_index + 1, pattern_index + 1) + ) + memo[key] = result + return result + + return visit(0, 0) + + return any(match_pattern(pattern) for pattern in patterns) + + +def segment_literal_prefix(pattern: str) -> str: + index = min((pattern.find(char) for char in "*?[" if char in pattern), default=len(pattern)) + return pattern[:index] + + +def segment_literal_suffix(pattern: str) -> str: + indexes = [pattern.rfind(char) for char in "*?]" if char in pattern] + return pattern[max(indexes, default=-1) + 1:] + + +def segments_may_overlap(first: str, second: str) -> bool: + first_magic = any(char in first for char in "*?[") + second_magic = any(char in second for char in "*?[") + if not first_magic and not second_magic: + return first == second + if not first_magic: + return fnmatch.fnmatchcase(first, second) + if not second_magic: + return fnmatch.fnmatchcase(second, first) + first_prefix = segment_literal_prefix(first) + second_prefix = segment_literal_prefix(second) + if first_prefix and second_prefix and not ( + first_prefix.startswith(second_prefix) or second_prefix.startswith(first_prefix) + ): + return False + first_suffix = segment_literal_suffix(first) + second_suffix = segment_literal_suffix(second) + if first_suffix and second_suffix and not ( + first_suffix.endswith(second_suffix) or second_suffix.endswith(first_suffix) + ): + return False + return True + + +def patterns_may_overlap(first: str, second: str) -> bool: + first_parts = first.replace("\\", "/").strip("/").split("/") + second_parts = second.replace("\\", "/").strip("/").split("/") + memo: dict[tuple[int, int], bool] = {} + + def visit(first_index: int, second_index: int) -> bool: + key = (first_index, second_index) + if key in memo: + return memo[key] + if first_index == len(first_parts) and second_index == len(second_parts): + result = True + elif first_index == len(first_parts): + result = all(part == "**" for part in second_parts[second_index:]) + elif second_index == len(second_parts): + result = all(part == "**" for part in first_parts[first_index:]) + elif first_parts[first_index] == "**" and second_parts[second_index] == "**": + result = visit(first_index + 1, second_index) or visit(first_index, second_index + 1) + elif first_parts[first_index] == "**": + result = visit(first_index + 1, second_index) or visit(first_index, second_index + 1) + elif second_parts[second_index] == "**": + result = visit(first_index, second_index + 1) or visit(first_index + 1, second_index) + else: + result = segments_may_overlap(first_parts[first_index], second_parts[second_index]) and visit( + first_index + 1, second_index + 1 + ) + memo[key] = result + return result + + return visit(0, 0) + + +def pattern_covered_by(pattern: str, owner_pattern: str) -> bool: + if pattern == owner_pattern: + return True + if not any(char in pattern for char in "*?["): + return matches(pattern, [owner_pattern]) + pattern_parts = pattern.replace("\\", "/").strip("/").split("/") + owner_parts = owner_pattern.replace("\\", "/").strip("/").split("/") + if owner_parts and owner_parts[-1] == "**" and len(pattern_parts) >= len(owner_parts) - 1: + prefix = owner_parts[:-1] + return all( + allowed == owned or ( + not any(char in allowed for char in "*?[") + and fnmatch.fnmatchcase(allowed, owned) + ) + for allowed, owned in zip(pattern_parts, prefix) + ) + return False def git_output(root: Path, args: list[str]) -> bytes: @@ -121,7 +269,10 @@ def git_output(root: Path, args: list[str]) -> bytes: def changed_paths(root: Path, base: str | None, head: str, explicit: list[str]) -> list[str]: if explicit: - return sorted(set(path.replace("\\", "/").removeprefix("./") for path in explicit if path)) + normalized = sorted(set(path.replace("\\", "/").removeprefix("./") for path in explicit if path)) + for path in normalized: + safe_repo_path(root, path) + return normalized try: if base: raw = git_output(root, ["diff", "--name-only", "-z", f"{base}...{head}"]) @@ -131,8 +282,8 @@ def changed_paths(root: Path, base: str | None, head: str, explicit: list[str]) untracked = git_output(root, ["ls-files", "--others", "--exclude-standard", "-z"]) paths = (tracked + untracked).decode("utf-8", "surrogateescape").split("\0") return sorted(set(path for path in paths if path)) - except (subprocess.CalledProcessError, FileNotFoundError): - return [] + except (subprocess.CalledProcessError, FileNotFoundError) as error: + raise RuntimeError("Git could not determine the changed-path set") from error def check_history_order( @@ -140,6 +291,7 @@ def check_history_order( base: str | None, head: str, ticket_name: str, + ticket_root: str, intent_path: str, governance_patterns: list[str], report: Report, @@ -149,13 +301,23 @@ def check_history_order( try: commits = git_output(root, ["rev-list", "--reverse", f"{base}..{head}"]).decode().splitlines() except (subprocess.CalledProcessError, FileNotFoundError): + report.add( + "GOV-DIFF-001", "Git could not enumerate commits for history-order validation.", + "Fetch the complete base/head history and rerun the governance gate.", + evidence={"base": base, "head": head}, + ) return first_implementation: tuple[int, str] | None = None for index, commit in enumerate(commits): try: raw = git_output(root, ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", commit]) except subprocess.CalledProcessError: - continue + report.add( + "GOV-DIFF-001", f"Git could not inspect commit {commit}.", + "Fetch complete commit objects and rerun the governance gate.", + evidence={"commit": commit}, + ) + return paths = [path for path in raw.decode("utf-8", "surrogateescape").split("\0") if path] if any(not matches(path, governance_patterns) for path in paths): first_implementation = (index, commit) @@ -164,7 +326,7 @@ def check_history_order( return index, commit = first_implementation parent = f"{commit}^" if index > 0 else base - ticket_intent = f"project/{ticket_name}/{intent_path}" + ticket_intent = f"{ticket_root.rstrip('/')}/{ticket_name}/{intent_path}" try: subprocess.run( ["git", "cat-file", "-e", f"{parent}:{ticket_intent}"], cwd=root, @@ -187,46 +349,96 @@ def basic_manifest_valid(manifest: Any) -> bool: standard = manifest.get("standard") ticket = manifest.get("ticket") docker = manifest.get("docker") + expected_ticket_fields = { + "root", "directoryPattern", "requiredFiles", "requiredAgentFiles", + "activeStatuses", "closedStatuses", "implementationStates", "intentFile", + } + if manifest.get("schema") == "new-project.governance/v2": + expected_ticket_fields.add("nonActiveStatuses") + status_groups = [ + set(ticket.get(name, [])) if isinstance(ticket, dict) else set() + for name in ("activeStatuses", "nonActiveStatuses", "closedStatuses") + ] common_valid = ( isinstance(standard, dict) + and set(standard) == {"id", "version"} and standard.get("id") == "wellmanifest/new-project" and isinstance(standard.get("version"), str) - and isinstance(manifest.get("requiredFiles"), list) - and isinstance(manifest.get("governancePaths"), list) - and isinstance(manifest.get("trustedApprovalSources"), list) + and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", standard["version"]) is not None + and string_list(manifest.get("requiredFiles")) + and string_list(manifest.get("governancePaths")) + and all(relative_pattern(item) for item in manifest["requiredFiles"]) + and all(relative_pattern(item) for item in manifest["governancePaths"]) + and string_list(manifest.get("trustedApprovalSources"), nonempty=True) + and set(manifest["trustedApprovalSources"]) <= { + "github-review", "github-app-review", "signed-attestation", + } + and approval_evidence_config_valid(manifest.get("approvalEvidence")) and isinstance(ticket, dict) - and all(key in ticket for key in ( - "root", "directoryPattern", "requiredFiles", "requiredAgentFiles", - "activeStatuses", "closedStatuses", "implementationStates", "intentFile", - )) + and set(ticket) == expected_ticket_fields + and isinstance(ticket.get("root"), str) and bool(ticket["root"]) and relative_pattern(ticket["root"]) + and isinstance(ticket.get("directoryPattern"), str) and bool(ticket["directoryPattern"]) + and string_list(ticket.get("requiredFiles")) + and string_list(ticket.get("requiredAgentFiles")) + and all(relative_pattern(item) for item in [*ticket["requiredFiles"], *ticket["requiredAgentFiles"]]) + and string_list(ticket.get("activeStatuses"), nonempty=True) + and (manifest.get("schema") != "new-project.governance/v2" or string_list(ticket.get("nonActiveStatuses"), nonempty=True)) + and string_list(ticket.get("closedStatuses"), nonempty=True) + and all(left.isdisjoint(right) for index, left in enumerate(status_groups) for right in status_groups[index + 1:]) + and string_list(ticket.get("implementationStates"), nonempty=True) + and isinstance(ticket.get("intentFile"), str) and bool(ticket["intentFile"]) and relative_pattern(ticket["intentFile"]) and isinstance(docker, dict) - and all(key in docker for key in ("required", "dockerfiles", "composeFiles")) + and set(docker) == {"required", "dockerfiles", "composeFiles"} + and isinstance(docker.get("required"), bool) + and string_list(docker.get("dockerfiles"), nonempty=True) + and string_list(docker.get("composeFiles"), nonempty=True) + and all(relative_pattern(item) for item in [*docker["dockerfiles"], *docker["composeFiles"]]) ) + if common_valid: + try: + re.compile(ticket["directoryPattern"]) + except re.error: + common_valid = False if not common_valid or manifest.get("schema") == "new-project.governance/v1": return common_valid + allowed_root_keys = { + "$schema", "schema", "standard", "requiredFiles", "governancePaths", + "trustedApprovalSources", "approvalEvidence", "ticket", "docker", + "coordination", "stacks", + } coordination = manifest.get("coordination") return ( - isinstance(coordination, dict) + set(manifest) <= allowed_root_keys + and string_list(manifest.get("stacks", [])) + and set(manifest.get("stacks", [])) <= {"node", "python", "go", "rust", "java", "docker", "frontend", "terraform", "kubernetes"} + and isinstance(coordination, dict) + and set(coordination) == {"mode", "maxActiveTicketsPerWorkstream", "rejectActiveScopeOverlap", "workstreams", "integration"} and coordination.get("mode") == "workstreams" and isinstance(coordination.get("maxActiveTicketsPerWorkstream"), int) + and not isinstance(coordination.get("maxActiveTicketsPerWorkstream"), bool) and coordination["maxActiveTicketsPerWorkstream"] >= 1 and isinstance(coordination.get("rejectActiveScopeOverlap"), bool) and isinstance(coordination.get("workstreams"), dict) and bool(coordination["workstreams"]) and all( isinstance(item, dict) - and isinstance(item.get("ownedPaths"), list) - and bool(item["ownedPaths"]) - for item in coordination["workstreams"].values() + and set(item) == {"ownedPaths"} + and string_list(item.get("ownedPaths"), nonempty=True) + and all(relative_pattern(path) for path in item["ownedPaths"]) + for name, item in coordination["workstreams"].items() + if isinstance(name, str) and re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) ) + and all(isinstance(name, str) and re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) for name in coordination["workstreams"]) and isinstance(coordination.get("integration"), dict) + and set(coordination["integration"]) == {"workstream", "requiredForPaths"} and isinstance(coordination["integration"].get("workstream"), str) - and isinstance(coordination["integration"].get("requiredForPaths"), list) + and string_list(coordination["integration"].get("requiredForPaths")) + and all(relative_pattern(item) for item in coordination["integration"]["requiredForPaths"]) and coordination["integration"]["workstream"] in coordination["workstreams"] ) -def check_lock(root: Path, lock_path: Path | None, report: Report) -> None: +def check_lock(root: Path, lock_path: Path | None, manifest: dict[str, Any], report: Report) -> None: if lock_path is None: return if not lock_path.is_file(): @@ -239,8 +451,28 @@ def check_lock(root: Path, lock_path: Path | None, report: Report) -> None: try: lock = load_json(lock_path) managed = lock["managedFiles"] - if lock.get("schema") != "new-project.lock/v1" or not isinstance(managed, dict): + standard = lock["standard"] + if lock.get("schema") != "new-project.lock/v1" or set(lock) != {"schema", "standard", "managedFiles"} or not isinstance(managed, dict): raise ValueError("unsupported lock schema") + if ( + not isinstance(standard, dict) + or set(standard) != {"id", "version", "sourceRepository", "sourceRevision", "publicationStatus"} + or standard.get("id") != "wellmanifest/new-project" + or standard.get("version") != manifest["standard"]["version"] + or standard.get("sourceRepository") != "wellmanifest/new-project" + or not isinstance(standard.get("sourceRevision"), str) + or re.fullmatch(r"[0-9a-f]{40}", standard["sourceRevision"]) is None + or standard.get("publicationStatus") != "published" + ): + raise ValueError("lock must identify the published immutable standard revision") + if not all( + isinstance(raw_path, str) + and relative_pattern(raw_path) + and isinstance(digest, str) + and re.fullmatch(r"[a-f0-9]{64}", digest) + for raw_path, digest in managed.items() + ): + raise ValueError("managedFiles must map repository-relative paths to lowercase SHA-256 digests") except (OSError, ValueError, KeyError, json.JSONDecodeError) as error: report.add("GOV-SYNC-001", f"Governance lock is invalid: {error}", "Regenerate the lock from a trusted standard release.", [rel(root, lock_path)]) return @@ -277,7 +509,10 @@ def ticket_directories(root: Path, config: dict[str, Any]) -> list[Path]: pattern = re.compile(config["directoryPattern"]) if not ticket_root.is_dir(): return [] - return sorted(path for path in ticket_root.iterdir() if path.is_dir() and pattern.fullmatch(path.name)) + return sorted( + path for path in ticket_root.iterdir() + if path.is_dir() and not path.is_symlink() and pattern.fullmatch(path.name) + ) def validate_intent(path: Path, ticket_name: str) -> tuple[dict[str, Any] | None, str | None]: @@ -299,10 +534,13 @@ def validate_intent(path: Path, ticket_name: str) -> tuple[dict[str, Any] | None if not isinstance(intent.get("summary"), str) or not intent["summary"].strip(): return None, "intent summary is blank" for field_name in ("allowedPaths", "forbiddenPaths", "stacks"): - if not isinstance(intent.get(field_name), list) or not all(isinstance(value, str) and value for value in intent[field_name]): + if not string_list(intent.get(field_name)): return None, f"intent {field_name} must be a list of non-blank strings" if not intent["allowedPaths"]: return None, "intent allowedPaths is empty" + for field_name in ("allowedPaths", "forbiddenPaths"): + if not all(relative_pattern(value) for value in intent[field_name]): + return None, f"intent {field_name} must contain repository-relative patterns" if intent["schema"] == "new-project.intent/v2": if not isinstance(intent.get("workstream"), str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", intent["workstream"]): return None, "intent workstream is invalid" @@ -315,6 +553,8 @@ def validate_intent(path: Path, ticket_name: str) -> tuple[dict[str, Any] | None integration = intent.get("integrationTicket") if integration is not None and (not isinstance(integration, str) or not re.fullmatch(r"ticket-[0-9]{3}", integration)): return None, "intent integrationTicket must be null or a ticket ID" + if integration == ticket_name: + return None, "intent integrationTicket cannot reference its own ticket" return intent, None @@ -348,7 +588,17 @@ def check_coordination( return config = manifest["ticket"] active_statuses = set(config.get("activeStatuses", ACTIVE_DEFAULT)) + non_active_statuses = set(config.get("nonActiveStatuses", [])) closed_statuses = set(config.get("closedStatuses", [])) + allowed_statuses = active_statuses | non_active_statuses | closed_statuses + for record in records: + if record.status not in allowed_statuses: + report.add( + "GOV-STATUS-001", f"Ticket {record.directory.name} has unknown status '{record.status or 'MISSING'}'.", + "Use a status declared in activeStatuses, nonActiveStatuses or closedStatuses.", + [rel(root, record.directory / "README.md")], + {"ticket": record.directory.name, "status": record.status, "allowedStatuses": sorted(allowed_statuses)}, + ) active = [record for record in records if record.status in active_statuses] by_name = {record.directory.name: record for record in records} workstreams = coordination["workstreams"] @@ -431,6 +681,7 @@ def visit(name: str, trail: list[str]) -> bool: active_names = {record.directory.name for record in active} conflict_pairs: set[tuple[str, str]] = set() + integration_config = coordination["integration"] for record in valid_active: assert record.intent is not None for dependency in record.intent["dependsOn"]: @@ -445,6 +696,24 @@ def visit(name: str, trail: list[str]) -> bool: for conflict in record.intent["conflictsWith"]: if conflict in active_names: conflict_pairs.add(tuple(sorted((record.directory.name, conflict)))) + integration_name = record.intent["integrationTicket"] + if integration_name is not None: + integration_record = by_name.get(integration_name) + valid_integration = ( + integration_record is not None + and integration_record.intent is not None + and integration_record.intent.get("schema") == "new-project.intent/v2" + and integration_record.intent.get("workstream") == integration_config["workstream"] + and integration_record.status != "CANCELLED" + ) + if not valid_integration: + report.add( + "GOV-INTEGRATION-001", + f"Ticket {record.directory.name} references an invalid integration ticket {integration_name}.", + "Reference an existing, non-cancelled ticket in the manifest-declared integration workstream.", + [rel(root, record.directory / config["intentFile"])], + {"ticket": record.directory.name, "integrationTicket": integration_name, "requiredWorkstream": integration_config["workstream"]}, + ) for first, second in sorted(conflict_pairs): report.add( "GOV-CONFLICT-001", f"Conflicting tickets {first} and {second} are active together.", @@ -457,6 +726,14 @@ def visit(name: str, trail: list[str]) -> bool: for record in valid_active: assert record.intent is not None owned_paths = workstreams[record.intent["workstream"]]["ownedPaths"] + implementation_patterns = [ + pattern for pattern in record.intent["allowedPaths"] + if not matches(pattern, governance_patterns) + ] + unowned_patterns = [ + pattern for pattern in implementation_patterns + if not any(pattern_covered_by(pattern, owned) for owned in owned_paths) + ] unowned_claims = [ path for path in files if not matches(path, governance_patterns) @@ -464,12 +741,18 @@ def visit(name: str, trail: list[str]) -> bool: and not matches(path, record.intent["forbiddenPaths"]) and not matches(path, owned_paths) ] - if unowned_claims: + if unowned_patterns or unowned_claims: report.add( - "GOV-WORKSTREAM-003", f"Ticket {record.directory.name} claims concrete paths outside workstream '{record.intent['workstream']}'.", - "Narrow allowedPaths or route the concrete files to their owning workstream/integration ticket and obtain fresh approval.", - unowned_claims[:20], - {"ticket": record.directory.name, "workstream": record.intent["workstream"], "ownedPaths": owned_paths, "concretePathCount": len(unowned_claims)}, + "GOV-WORKSTREAM-003", f"Ticket {record.directory.name} claims paths outside workstream '{record.intent['workstream']}'.", + "Narrow allowedPaths or route the paths to their owning workstream/integration ticket and obtain fresh approval.", + sorted(set([*unowned_patterns, *unowned_claims]))[:20], + { + "ticket": record.directory.name, + "workstream": record.intent["workstream"], + "ownedPaths": owned_paths, + "unownedPatterns": unowned_patterns, + "concretePathCount": len(unowned_claims), + }, ) if coordination["rejectActiveScopeOverlap"]: @@ -485,17 +768,21 @@ def visit(name: str, trail: list[str]) -> bool: and matches(path, second.intent["allowedPaths"]) and not matches(path, second.intent["forbiddenPaths"]) ] - common_patterns = sorted( - (set(first.intent["allowedPaths"]) & set(second.intent["allowedPaths"])) - - set(governance_patterns) - ) - if shared_files or common_patterns: + first_patterns = [pattern for pattern in first.intent["allowedPaths"] if not matches(pattern, governance_patterns)] + second_patterns = [pattern for pattern in second.intent["allowedPaths"] if not matches(pattern, governance_patterns)] + overlapping_patterns = sorted({ + f"{first_pattern} <-> {second_pattern}" + for first_pattern in first_patterns + for second_pattern in second_patterns + if patterns_may_overlap(first_pattern, second_pattern) + }) + if shared_files or overlapping_patterns: report.add( "GOV-WORKSTREAM-004", f"Active ticket scopes overlap: {first.directory.name} and {second.directory.name}.", "Narrow one allowedPaths declaration, serialize the work, or route the shared contract through integration.", shared_files[:20], - {"tickets": [first.directory.name, second.directory.name], "commonPatterns": common_patterns, "concretePathCount": len(shared_files)}, + {"tickets": [first.directory.name, second.directory.name], "overlappingPatterns": overlapping_patterns, "concretePathCount": len(shared_files)}, ) @@ -512,8 +799,17 @@ def check_required_files(root: Path, manifest: dict[str, Any], report: Report) - docker = manifest["docker"] if docker["required"]: - dockerfile = next((name for name in docker["dockerfiles"] if safe_repo_path(root, name).is_file()), None) - compose = next((name for name in docker["composeFiles"] if safe_repo_path(root, name).is_file()), None) + def first_repo_file(names: list[str]) -> str | None: + for name in names: + try: + if safe_repo_path(root, name).is_file(): + return name + except ValueError: + continue + return None + + dockerfile = first_repo_file(docker["dockerfiles"]) + compose = first_repo_file(docker["composeFiles"]) if dockerfile is None or compose is None: report.add( "GOV-DOCKER-001", "Required Dockerfile or Compose declaration is missing.", @@ -528,7 +824,9 @@ def check_stacks(root: Path, manifest: dict[str, Any], profiles_path: Path | Non return try: profiles = load_json(profiles_path)["profiles"] - except (OSError, KeyError, json.JSONDecodeError): + if not isinstance(profiles, dict): + raise ValueError("profiles must be an object") + except (OSError, KeyError, ValueError, json.JSONDecodeError): report.add("GOV-MANIFEST-001", "Stack profile catalog is unreadable.", "Restore the pinned stack profile catalog.", []) return for stack in stacks: @@ -537,6 +835,9 @@ def check_stacks(root: Path, manifest: dict[str, Any], profiles_path: Path | Non report.add("GOV-STACK-001", f"Unknown stack profile: {stack}", "Declare a profile published by the pinned governance standard.", []) continue markers = profile.get("anyFiles", []) + if not string_list(markers) or not all(relative_pattern(marker) for marker in markers): + report.add("GOV-MANIFEST-001", f"Stack profile '{stack}' has invalid markers.", "Restore the pinned stack profile catalog.", []) + continue if markers and not any(safe_repo_path(root, marker).exists() for marker in markers): report.add("GOV-STACK-001", f"Declared stack '{stack}' has no recognized project marker.", "Add the stack marker or remove the inaccurate stack declaration.", markers) @@ -600,6 +901,130 @@ def check_changed_content(root: Path, changed: list[str], actor: str, trusted_hu ) +def approval_evidence( + root: Path, + raw_path: str | None, + manifest: dict[str, Any], + expected_repository: str | None, + expected_pull_request: int | None, + expected_head: str | None, + report: Report, +) -> dict[str, Any] | None: + if not raw_path: + return None + path = Path(raw_path).expanduser().resolve() + if path.is_relative_to(root): + report.add( + "GOV-APPROVAL-003", + "Approval evidence is controlled by the pull-request checkout.", + "Create evidence outside the checkout from a protected workflow after API or signature verification.", + [rel(root, path)], + ) + return None + try: + evidence = load_json(path) + except (OSError, json.JSONDecodeError) as error: + report.add( + "GOV-APPROVAL-003", f"Approval evidence is unreadable: {error}", + "Have the protected approval resolver create a valid v1 evidence document outside the checkout.", + ) + return None + required = { + "schema", "source", "repository", "pullRequest", "headSha", "ticket", + "actor", "verification", + } + actor = evidence.get("actor") if isinstance(evidence, dict) else None + verification = evidence.get("verification") if isinstance(evidence, dict) else None + structurally_valid = ( + isinstance(evidence, dict) + and set(evidence) == required + and evidence.get("schema") == "new-project.approval-evidence/v1" + and evidence.get("source") in { + "github-review", "github-app-review", "signed-attestation", + } + and isinstance(evidence.get("repository"), str) + and re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", evidence["repository"]) is not None + and isinstance(evidence.get("pullRequest"), int) + and not isinstance(evidence.get("pullRequest"), bool) + and evidence["pullRequest"] >= 1 + and isinstance(evidence.get("headSha"), str) + and re.fullmatch(r"[0-9a-f]{40}", evidence["headSha"]) is not None + and isinstance(evidence.get("ticket"), str) + and re.fullmatch(r"ticket-[0-9]{3}", evidence["ticket"]) is not None + and isinstance(actor, dict) + and set(actor) == {"login", "type"} + and isinstance(actor.get("login"), str) and bool(actor["login"]) + and actor.get("type") in {"User", "Bot", "Workflow"} + and isinstance(verification, dict) + and {"method", "verified"} <= set(verification) + and set(verification) <= {"method", "verified", "issuer", "predicateType"} + and verification.get("method") in { + "github-api-allowlist", "github-attestation", "sigstore", + } + and verification.get("verified") is True + ) + if not structurally_valid: + report.add( + "GOV-APPROVAL-003", "Approval evidence does not conform to new-project.approval-evidence/v1.", + "Regenerate evidence with the protected resolver and the pinned approval-evidence schema.", + ) + return None + missing_expectation = ( + expected_repository is None or expected_pull_request is None or expected_head is None + or re.fullmatch(r"[0-9a-f]{40}", expected_head or "") is None + ) + bindings = { + "repository": (evidence["repository"], expected_repository), + "pullRequest": (evidence["pullRequest"], expected_pull_request), + "headSha": (evidence["headSha"], expected_head), + } + mismatches = { + name: {"evidence": supplied, "expected": expected} + for name, (supplied, expected) in bindings.items() + if supplied != expected + } + if missing_expectation or mismatches: + report.add( + "GOV-APPROVAL-004", + "Approval evidence is not bound to the current repository, pull request and HEAD.", + "Pass the current protected event bindings and request a fresh approval for the exact HEAD.", + evidence={"missingExpectedBinding": missing_expectation, "mismatches": mismatches}, + ) + source = evidence["source"] + actor_type = actor["type"] + method = verification["method"] + authority_valid = False + if source == "github-review": + authority_valid = actor_type == "User" and method == "github-api-allowlist" + elif source == "github-app-review": + authority_valid = ( + actor_type == "Bot" + and actor["login"].endswith("[bot]") + and method == "github-api-allowlist" + ) + else: + approval_config = manifest.get("approvalEvidence") or {} + expected_predicate = approval_config.get( + "signedAttestationPredicateType", + "https://wellmanifest.dev/attestations/validator/v1", + ) + authority_valid = ( + actor_type in {"Bot", "Workflow"} + and method in {"github-attestation", "sigstore"} + and isinstance(verification.get("issuer"), str) + and bool(verification["issuer"]) + and verification.get("predicateType") == expected_predicate + ) + if not authority_valid: + report.add( + "GOV-APPROVAL-005", + "Approval actor or verification method is not valid for the claimed source.", + "Use an allowlisted User, an allowlisted GitHub App bot login, or a signature-verified trusted attestation issuer.", + evidence={"source": source, "actor": actor, "verification": verification}, + ) + return evidence + + def check_change_gate( root: Path, manifest: dict[str, Any], @@ -609,13 +1034,17 @@ def check_change_gate( head: str, approval_source: str | None, approved_ticket: str | None, + approval_evidence_path: str | None, + expected_repository: str | None, + expected_pull_request: int | None, + expected_head: str | None, enforce_approval: bool, report: Report, -) -> None: +) -> str | None: governance_patterns = manifest["governancePaths"] implementation = [path for path in changed if not matches(path, governance_patterns)] if not implementation: - return + return None config = manifest["ticket"] active = [record for record in records if record.status in set(config.get("activeStatuses", ACTIVE_DEFAULT))] if not active: @@ -623,7 +1052,7 @@ def check_change_gate( "GOV-TICKET-001", "Implementation paths changed without an active ticket.", "Create the next target-repository ticket, publish its plan and obtain approval before editing implementation.", implementation, ) - return + return None coordination = manifest.get("coordination") if not isinstance(coordination, dict): if len(active) > 1: @@ -632,7 +1061,7 @@ def check_change_gate( "Continue the existing ticket or close/cancel it before creating another.", [rel(root, item.directory) for item in active], {"tickets": [item.directory.name for item in active]}, ) - return + return None selected = active[0] else: candidates = [ @@ -664,11 +1093,12 @@ def check_change_gate( "Use one ticket per branch/PR, narrow allowedPaths, or create an approved integration ticket for the combined diff.", implementation, {"candidateTickets": [record.directory.name for record in candidates], "pathOwners": path_owners}, ) - return + return None directory = selected.directory workflow = selected.workflow check_history_order( root, base=base, head=head, ticket_name=directory.name, + ticket_root=config["root"], intent_path=config["intentFile"], governance_patterns=governance_patterns, report=report, ) @@ -711,13 +1141,31 @@ def check_change_gate( and integration_record.intent.get("workstream") == integration["workstream"] and integration_record.status != "CANCELLED" ) - if not valid_integration: - report.add( - "GOV-INTEGRATION-001", "Shared contract paths lack valid integration-ticket routing.", - "Create an integration-workstream ticket, record it in integrationTicket and obtain fresh approval before changing the shared contract.", - shared, {"ticket": directory.name, "integrationTicket": integration_name, "requiredWorkstream": integration["workstream"]}, - ) + report.add( + "GOV-INTEGRATION-001", "Shared contract paths must be changed by the integration-workstream ticket.", + "Move the shared-path diff to the referenced integration ticket's branch; integrationTicket coordinates work but does not transfer path ownership.", + shared, + { + "ticket": directory.name, + "integrationTicket": integration_name, + "validIntegrationReference": valid_integration, + "requiredWorkstream": integration["workstream"], + }, + ) if enforce_approval: + supplied_evidence = approval_evidence( + root, approval_evidence_path, manifest, expected_repository, + expected_pull_request, expected_head, report, + ) + if supplied_evidence is not None: + approval_source = supplied_evidence["source"] + approved_ticket = supplied_evidence["ticket"] + elif approval_source in {"github-app-review", "signed-attestation"}: + report.add( + "GOV-APPROVAL-003", + f"Approval source {approval_source} requires external v1 evidence.", + "Create bound evidence outside the checkout after allowlist or signature verification.", + ) trusted = set(manifest["trustedApprovalSources"]) if approval_source not in trusted: report.add( @@ -732,6 +1180,7 @@ def check_change_gate( "Approve the current ticket after reviewing its latest intent and implementation diff.", [rel(root, directory)], {"activeTicket": directory.name, "approvedTickets": sorted(approved_tickets)}, ) + return directory.name def sarif(payload: dict[str, Any]) -> dict[str, Any]: @@ -790,6 +1239,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--enforce-approval", action="store_true") parser.add_argument("--approval-source") parser.add_argument("--approved-ticket") + parser.add_argument("--approval-evidence") + parser.add_argument("--expected-repository") + parser.add_argument("--expected-pull-request", type=int) + parser.add_argument("--expected-head") + parser.add_argument("--resolved-ticket-output") parser.add_argument("--format", choices=["text", "json", "sarif"], default="text") parser.add_argument("--output") return parser.parse_args(argv) @@ -799,6 +1253,7 @@ def main(argv: list[str] | None = None) -> int: args = parse_args(argv or sys.argv[1:]) root = Path(args.root).resolve() report = Report(root) + selected_ticket: str | None = None try: manifest_path = safe_repo_path(root, args.manifest) except ValueError as error: @@ -814,10 +1269,26 @@ def main(argv: list[str] | None = None) -> int: manifest = None if manifest is not None: - lock_path = safe_repo_path(root, args.lock) if args.lock else None - profiles_path = safe_repo_path(root, args.stack_profiles) if args.stack_profiles else None - changed = changed_paths(root, args.base, args.head, args.changed_file) - check_lock(root, lock_path, report) + try: + lock_path = safe_repo_path(root, args.lock) if args.lock else None + except ValueError as error: + report.add("GOV-SYNC-001", str(error), "Use a repository-relative governance lock path.", [args.lock]) + lock_path = None + try: + profiles_path = safe_repo_path(root, args.stack_profiles) if args.stack_profiles else None + except ValueError as error: + report.add("GOV-MANIFEST-001", str(error), "Use a repository-relative stack-profile path.", [args.stack_profiles]) + profiles_path = None + try: + changed = changed_paths(root, args.base, args.head, args.changed_file) + except (RuntimeError, ValueError) as error: + report.add( + "GOV-DIFF-001", str(error), + "Use repository-relative changed paths and fetch the complete base/head history before retrying.", + evidence={"base": args.base, "head": args.head}, + ) + changed = [] + check_lock(root, lock_path, manifest, report) check_required_files(root, manifest, report) check_stacks(root, manifest, profiles_path, report) directories = ticket_directories(root, manifest["ticket"]) @@ -825,11 +1296,35 @@ def main(argv: list[str] | None = None) -> int: records = load_ticket_records(directories, manifest["ticket"]) check_coordination(root, manifest, records, changed, report) check_changed_content(root, changed, args.actor, args.trusted_human_change, report) - check_change_gate( + selected_ticket = check_change_gate( root, manifest, records, changed, args.base, args.head, args.approval_source, - args.approved_ticket, args.enforce_approval, report, + args.approved_ticket, args.approval_evidence, args.expected_repository, + args.expected_pull_request, args.expected_head, args.enforce_approval, report, ) + if args.resolved_ticket_output and selected_ticket and report.errors == 0: + resolved_path = Path(args.resolved_ticket_output).expanduser().resolve() + if resolved_path.is_relative_to(root): + report.add( + "GOV-PATH-001", "Resolved ticket output must be outside the repository checkout.", + "Write ephemeral approval context to runner.temp or another protected directory.", + [rel(root, resolved_path)], + ) + else: + try: + resolved_path.write_text(f"{selected_ticket}\n", encoding="utf-8") + except OSError as error: + report.add( + "GOV-PATH-001", f"Could not write resolved ticket output: {error}", + "Use a writable protected directory outside the checkout.", + ) + + output_path = None + if args.output: + try: + output_path = safe_repo_path(root, args.output) + except ValueError as error: + report.add("GOV-PATH-001", str(error), "Use a repository-relative report output path.", [args.output]) payload = report.payload() if args.format == "json": output = json.dumps(payload, indent=2, sort_keys=True) + "\n" @@ -837,8 +1332,7 @@ def main(argv: list[str] | None = None) -> int: output = json.dumps(sarif(payload), indent=2, sort_keys=True) + "\n" else: output = render_text(payload) - if args.output: - output_path = safe_repo_path(root, args.output) + if output_path is not None: output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(output, encoding="utf-8") else: diff --git a/.governance/intent.schema.json b/.governance/intent.schema.json index 4f7749c..83c667b 100644 --- a/.governance/intent.schema.json +++ b/.governance/intent.schema.json @@ -10,8 +10,8 @@ "ticket": { "type": "string", "pattern": "^ticket-[0-9]{3}$" }, "summary": { "type": "string", "minLength": 1 }, "workstream": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, - "allowedPaths": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "uniqueItems": true }, - "forbiddenPaths": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "allowedPaths": { "type": "array", "items": { "$ref": "#/$defs/glob" }, "minItems": 1, "uniqueItems": true }, + "forbiddenPaths": { "type": "array", "items": { "$ref": "#/$defs/glob" }, "uniqueItems": true }, "stacks": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, "dependsOn": { "type": "array", "items": { "type": "string", "pattern": "^ticket-[0-9]{3}$" }, "uniqueItems": true }, "conflictsWith": { "type": "array", "items": { "type": "string", "pattern": "^ticket-[0-9]{3}$" }, "uniqueItems": true }, @@ -21,5 +21,8 @@ { "type": "string", "pattern": "^ticket-[0-9]{3}$" } ] } + }, + "$defs": { + "glob": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" } } } diff --git a/.governance/lock.schema.json b/.governance/lock.schema.json new file mode 100644 index 0000000..27b9511 --- /dev/null +++ b/.governance/lock.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/wellmanifest/new-project/governance/lock.schema.json", + "title": "new-project governance lock", + "type": "object", + "additionalProperties": false, + "required": ["schema", "standard", "managedFiles"], + "properties": { + "schema": { "const": "new-project.lock/v1" }, + "standard": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "sourceRepository", "sourceRevision", "publicationStatus"], + "properties": { + "id": { "const": "wellmanifest/new-project" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "sourceRepository": { "const": "wellmanifest/new-project" }, + "sourceRevision": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "publicationStatus": { "const": "published" } + } + }, + "managedFiles": { + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" }, + "additionalProperties": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + } +} \ No newline at end of file diff --git a/.governance/manifest.json b/.governance/manifest.json index 60834d1..7ac84be 100644 --- a/.governance/manifest.json +++ b/.governance/manifest.json @@ -3,7 +3,7 @@ "schema": "new-project.governance/v2", "standard": { "id": "wellmanifest/new-project", - "version": "0.8.0" + "version": "0.9.0" }, "requiredFiles": [ "README.md", @@ -24,8 +24,21 @@ ], "trustedApprovalSources": [ "github-review", + "github-app-review", "signed-attestation" ], + "approvalEvidence": { + "schema": "new-project.approval-evidence/v1", + "requiredBindings": [ + "repository", + "pullRequest", + "headSha", + "ticket", + "actor" + ], + "reviewVerificationMethod": "github-api-allowlist", + "signedAttestationPredicateType": "https://wellmanifest.dev/attestations/validator/v1" + }, "ticket": { "root": "project", "directoryPattern": "^ticket-[0-9]{3}$", @@ -40,8 +53,11 @@ "ai-*-logs.txt" ], "activeStatuses": [ + "IN_PROGRESS" + ], + "nonActiveStatuses": [ + "BACKLOG", "PLAN", - "IN_PROGRESS", "BLOCKED" ], "closedStatuses": [ diff --git a/.governance/manifest.lock.json b/.governance/manifest.lock.json index 7da0595..8e3e063 100644 --- a/.governance/manifest.lock.json +++ b/.governance/manifest.lock.json @@ -1,23 +1,27 @@ { - "schema": "new-project.lock/v1", - "standard": { - "id": "wellmanifest/new-project", - "version": "0.8.0", - "sourceRepository": "wellmanifest/new-project", - "sourceRevision": null, - "sourceBaseRevision": "72e5f6c9cf91998615e2342f02b2af650be81cea", - "publicationStatus": "uncommitted" - }, "managedFiles": { - ".governance/diagnostics.json": "2a6d1e088a03badb75eef33cfeb9b6c9992fea4c6f7fa5ebec6257b1eea9e39f", - ".governance/governance_check.py": "1e45843a4efa5793547aa7e9a0fd629b495449c65ca6a4cf7b0990334545bbfa", - ".governance/intent.schema.json": "7e3157c1bf7c987541fc2182fc44d33bf53520672931a09bbd6b2b10b821aa3b", - ".governance/manifest.json": "8d3f8048f9112e467832d3c82121653ab976eeeb14f160348c0d24fd94230c27", - ".governance/manifest.schema.json": "185f041ffe3d9c40670765ff53fc7ef37dc4ee21121c67a8d7913bd8860435da", + ".governance/approval-evidence.schema.json": "488dee5a4bfbf221206acc45947fce5283eb5e80614ec0ef478b5d618cc4eb83", + ".governance/diagnostics.json": "c8c3b8f6f618c103d67cb6e6c3221ec2980959041a1187f754c5f824cf7166b0", + ".governance/governance_check.py": "b5429a616a2c1a3f61c80aed7b4514e8b1a05f3c7ccae7e6cc3b05a2eee814b6", + ".governance/intent.schema.json": "b2dc37ee348ca33e2f0d33515dd79c2403ac8afb504257cdebb65edf472a2637", + ".governance/lock.schema.json": "fc6f1143ef713c993b61270dd2d7545a52cb0b8501aadb188e6d0152a208b207", + ".governance/manifest.json": "23763ce5ad1ba7bbcf0fe642b0d9b06b074eadf1df4c879d1388ae021c714d8f", + ".governance/manifest.schema.json": "33cb8154e363b2a147a1499eef116f329d8d513d3a3c8cc9c5ba5f67d5230c41", ".governance/stack-profiles.json": "6fa3f8f44e50cfd0539413a85092817d3fab4e82fe376405f1e443120724dff2", + "AGENTS.md": "e3928661a4e3ede5e9e60d8c0b28d4f6bb08057994a290da10197170f38a108d", + "project.bat": "d707e014dba4d66e64ff6d4e212ceeedb8b65ec96ce76677cc3fc025875f45f6", + "project.sh": "90f82d9f0feea9bde34dca3e1c657604a65f3bfc938709e3cf69682f07dd1bc1", "project/governance-check.bat": "7207bc499483d7a7a1ab2c230ad288c2484cdf02f3a773ba69f4b760b67a3388", "project/governance-check.sh": "158ca61531b8e51ba484de8eb6f91f4e4fbba908ae3c8db678b63bf6bab49923", - "project/new-ticket.sh": "0e6d199c535259bf1eebbc91f8eab68ae6457c3158230f55cf23d587fdb671ff", + "project/new-ticket.sh": "ee13b5a73afe18ff85ce36b00ae5b3d6811c96a336ce64dd55078061e77bfe29", "project/readme.sh": "8a19819ab97fff26dbca179ead831d697f785c48774d8154714d768968fcfaf0" + }, + "schema": "new-project.lock/v1", + "standard": { + "id": "wellmanifest/new-project", + "publicationStatus": "published", + "sourceRepository": "wellmanifest/new-project", + "sourceRevision": "d082373f314191dba794aba58aca2d4475ea497a", + "version": "0.9.0" } } diff --git a/.governance/manifest.schema.json b/.governance/manifest.schema.json index b03f700..86bfd39 100644 --- a/.governance/manifest.schema.json +++ b/.governance/manifest.schema.json @@ -21,20 +21,46 @@ "governancePaths": { "type": "array", "items": { "$ref": "#/$defs/glob" }, "uniqueItems": true }, "trustedApprovalSources": { "type": "array", - "items": { "enum": ["github-review", "signed-attestation"] }, + "items": { "enum": ["github-review", "github-app-review", "signed-attestation"] }, "minItems": 1, "uniqueItems": true }, + "approvalEvidence": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "requiredBindings", "reviewVerificationMethod", "signedAttestationPredicateType"], + "properties": { + "schema": { "const": "new-project.approval-evidence/v1" }, + "requiredBindings": { + "type": "array", + "prefixItems": [ + { "const": "repository" }, + { "const": "pullRequest" }, + { "const": "headSha" }, + { "const": "ticket" }, + { "const": "actor" } + ], + "items": false, + "minItems": 5, + "maxItems": 5 + }, + "reviewVerificationMethod": { "const": "github-api-allowlist" }, + "signedAttestationPredicateType": { + "const": "https://wellmanifest.dev/attestations/validator/v1" + } + } + }, "ticket": { "type": "object", "additionalProperties": false, - "required": ["root", "directoryPattern", "requiredFiles", "requiredAgentFiles", "activeStatuses", "closedStatuses", "implementationStates", "intentFile"], + "required": ["root", "directoryPattern", "requiredFiles", "requiredAgentFiles", "activeStatuses", "nonActiveStatuses", "closedStatuses", "implementationStates", "intentFile"], "properties": { "root": { "$ref": "#/$defs/path" }, "directoryPattern": { "type": "string", "minLength": 1 }, "requiredFiles": { "type": "array", "items": { "$ref": "#/$defs/path" }, "uniqueItems": true }, "requiredAgentFiles": { "type": "array", "items": { "$ref": "#/$defs/glob" }, "uniqueItems": true }, "activeStatuses": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true }, + "nonActiveStatuses": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true }, "closedStatuses": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true }, "implementationStates": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true }, "intentFile": { "$ref": "#/$defs/path" } @@ -90,7 +116,7 @@ } }, "$defs": { - "path": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" }, - "glob": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" } + "path": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" }, + "glob": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:[\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$" } } } diff --git a/.governance/resolve-approval.mjs b/.governance/resolve-approval.mjs new file mode 100644 index 0000000..0daf427 --- /dev/null +++ b/.governance/resolve-approval.mjs @@ -0,0 +1,80 @@ +const BOT_LOGIN = /^[A-Za-z0-9-]+\[bot\]$/; + +export function trustedGithubApps(manifest) { + const actors = manifest?.trustedApprovalActors; + if (!actors || typeof actors !== 'object' || Array.isArray(actors) + || Object.keys(actors).length !== 1 || !Array.isArray(actors.githubApps) + || actors.githubApps.length === 0) { + throw new Error('trustedApprovalActors.githubApps must be a non-empty array'); + } + const logins = new Set(); + for (const actor of actors.githubApps) { + if (!actor || typeof actor !== 'object' || Array.isArray(actor) + || Object.keys(actor).sort().join(',') !== 'login,type' + || actor.type !== 'Bot' || typeof actor.login !== 'string' + || !BOT_LOGIN.test(actor.login)) { + throw new Error('trusted GitHub App actor must contain only a valid bot login and type=Bot'); + } + const normalized = actor.login.toLowerCase(); + if (logins.has(normalized)) throw new Error('trusted GitHub App actor logins must be unique'); + logins.add(normalized); + } + return logins; +} + +export function resolveTrustedApproval({ reviews, authorLogin, headSha, activeTickets, manifest }) { + if (!Array.isArray(reviews) || typeof authorLogin !== 'string' + || !/^[0-9a-f]{40}$/.test(headSha) || !Array.isArray(activeTickets) + || activeTickets.some(ticket => !/^ticket-[0-9]{3}$/.test(ticket))) { + throw new Error('approval resolver input is malformed'); + } + const trustedApps = trustedGithubApps(manifest); + const latest = new Map(); + for (const review of reviews) { + const login = review?.user?.login; + if (typeof login !== 'string') continue; + const key = login.toLowerCase(); + const previous = latest.get(key); + if (!previous || isAtLeastAsNew(review, previous)) latest.set(key, review); + } + const candidates = [...latest.values()].filter(review => { + const login = review?.user?.login; + const type = review?.user?.type; + if (review?.state !== 'APPROVED' || review?.commit_id !== headSha + || typeof login !== 'string' || login.toLowerCase() === authorLogin.toLowerCase()) { + return false; + } + if (type === 'User') return true; + if (type !== 'Bot' || !trustedApps.has(login.toLowerCase())) return false; + const binding = reviewBinding(review.body); + return binding !== null && activeTickets.includes(binding.ticket); + }).sort((left, right) => left.user.login.localeCompare(right.user.login)); + + if (candidates.length === 0) { + return { approved: false, source: 'none', actor: null, actorType: null, approvedTickets: [] }; + } + const selected = candidates[0]; + const binding = selected.user.type === 'Bot' ? reviewBinding(selected.body) : null; + return { + approved: true, + source: 'github-review', + actor: selected.user.login, + actorType: selected.user.type, + approvedTickets: binding ? [binding.ticket] : [...activeTickets].sort(), + correlationId: binding?.correlationId ?? null, + }; +} + +function reviewBinding(body) { + if (typeof body !== 'string') return null; + const ticket = body.match(/^Ticket:\s+`(ticket-[0-9]{3})`\s*$/m)?.[1]; + const correlationId = body.match(/^Correlation ID:\s+`([A-Za-z0-9][A-Za-z0-9._-]{0,127})`\s*$/m)?.[1]; + return ticket && correlationId ? { ticket, correlationId } : null; +} + +function isAtLeastAsNew(candidate, previous) { + const candidateTime = Date.parse(candidate?.submitted_at || '') || 0; + const previousTime = Date.parse(previous?.submitted_at || '') || 0; + if (candidateTime !== previousTime) return candidateTime > previousTime; + return Number(candidate?.id || 0) >= Number(previous?.id || 0); +} diff --git a/.governance/resolve-approval.test.mjs b/.governance/resolve-approval.test.mjs new file mode 100644 index 0000000..bb08668 --- /dev/null +++ b/.governance/resolve-approval.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveTrustedApproval, trustedGithubApps } from './resolve-approval.mjs'; + +const headSha = 'a'.repeat(40); +const manifest = { + trustedApprovalActors: { + githubApps: [{ login: 'ifuri-validator-agent[bot]', type: 'Bot' }], + }, +}; +const activeTickets = ['ticket-034']; + +const review = (overrides = {}) => ({ + id: 1, + state: 'APPROVED', + commit_id: headSha, + submitted_at: '2026-08-04T10:00:00Z', + user: { login: 'reviewer', type: 'User' }, + body: '', + ...overrides, +}); + +test('accepts an independent human User approval on the exact head', () => { + const result = resolveTrustedApproval({ reviews: [review()], authorLogin: 'author', headSha, activeTickets, manifest }); + assert.deepEqual(result, { + approved: true, source: 'github-review', actor: 'reviewer', actorType: 'User', + approvedTickets: ['ticket-034'], correlationId: null, + }); +}); + +test('accepts only the exact allowlisted GitHub App on the exact head', () => { + const app = review({ + user: { login: 'ifuri-validator-agent[bot]', type: 'Bot' }, + body: 'Ticket: `ticket-034`\nCorrelation ID: `todo2code-pr-13-head`', + }); + const unknown = review({ id: 2, user: { login: 'unknown[bot]', type: 'Bot' } }); + const result = resolveTrustedApproval({ reviews: [unknown, app], authorLogin: 'author', headSha, activeTickets, manifest }); + assert.equal(result.approved, true); + assert.equal(result.actor, 'ifuri-validator-agent[bot]'); + assert.deepEqual(result.approvedTickets, ['ticket-034']); + assert.equal(result.correlationId, 'todo2code-pr-13-head'); +}); + +test('rejects unknown bots, stale commits, same-author and non-approved reviews', () => { + const fixtures = [ + review({ user: { login: 'unknown[bot]', type: 'Bot' } }), + review({ commit_id: 'b'.repeat(40) }), + review({ user: { login: 'author', type: 'User' } }), + review({ state: 'DISMISSED' }), + review({ state: 'CHANGES_REQUESTED' }), + ]; + for (const candidate of fixtures) { + const result = resolveTrustedApproval({ reviews: [candidate], authorLogin: 'author', headSha, activeTickets, manifest }); + assert.equal(result.approved, false); + } +}); + +test('latest review state for an actor wins and stale approval cannot survive dismissal', () => { + const approved = review(); + const dismissed = review({ id: 2, state: 'DISMISSED', submitted_at: '2026-08-04T11:00:00Z' }); + const result = resolveTrustedApproval({ reviews: [approved, dismissed], authorLogin: 'author', headSha, activeTickets, manifest }); + assert.equal(result.approved, false); +}); + +test('allowlisted App must bind an active ticket and safe correlation ID', () => { + const app = (body) => review({ + user: { login: 'ifuri-validator-agent[bot]', type: 'Bot' }, body, + }); + for (const body of [ + '', + 'Ticket: `ticket-999`\nCorrelation ID: `safe`', + 'Ticket: `ticket-034`', + 'Ticket: `ticket-034`\nCorrelation ID: `unsafe value`', + ]) { + const result = resolveTrustedApproval({ reviews: [app(body)], authorLogin: 'author', headSha, activeTickets, manifest }); + assert.equal(result.approved, false); + } +}); + +test('rejects malformed and duplicate trusted App allowlists', () => { + const malformed = [ + {}, + { trustedApprovalActors: { githubApps: [] } }, + { trustedApprovalActors: { githubApps: [{ login: 'human', type: 'Bot' }] } }, + { trustedApprovalActors: { githubApps: [{ login: 'app[bot]', type: 'User' }] } }, + { trustedApprovalActors: { githubApps: [ + { login: 'app[bot]', type: 'Bot' }, { login: 'APP[bot]', type: 'Bot' }, + ] } }, + ]; + for (const value of malformed) assert.throws(() => trustedGithubApps(value)); +}); diff --git a/AGENTS.md b/AGENTS.md index 0888586..527cbbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,33 +1,44 @@ # AGENTS.md -This repository follows `wellmanifest/new-project` policy-as-code version -0.8.0. These rules apply to humans and autonomous agents. +This target repository follows `wellmanifest/new-project` policy-as-code. -Before any multi-step implementation: +Before any multi-step implementation, an agent must: 1. Read `.governance/manifest.json`, `TODO.md`, `project/TICKETS.md` and the - active `project/ticket-{NNN}`. -2. Reuse an unfinished ticket when its workstream and scope match. A separate - active ticket is allowed only in another declared workstream with no write - overlap. Otherwise run `./project/new-ticket.sh --title "..." --agent - "..." --workstream "..."`. -3. Complete `README.md`, the actor-owned `ai-*.md`, `intent.json` and `TODO.md`. -4. Stop in `WAIT_FOR_APPROVAL`. Do not edit source, tests, build files or CI. -5. After explicit human approval, transition to `EDIT` and modify only paths - matched by `intent.json.allowedPaths`. -6. Never create or edit `project/ticket-*/user-*.md`; only the human owner or a + active ticket. +2. Reuse an unfinished ticket whose workstream and scope match. A second active + ticket is allowed only in a distinct workstream with no write-scope overlap. + Otherwise run `./project/new-ticket.sh --title "..." --agent "..." + --workstream "..."`. +3. Complete the ticket `README.md`, owned `ai-*.md`, `intent.json` and `TODO.md`. +4. Stop in `WAIT_FOR_APPROVAL`; do not change implementation files yet. +5. After explicit approval, move to `EDIT` and stay inside `intent.json` + `allowedPaths`. +6. Never create or edit `project/ticket-*/user-*.md`; only its human owner or a trusted intake boundary may do so. -7. Keep executable code, tests and research scripts outside ticket directories. -8. Run `make governance` plus relevant Docker/stack checks before completion. -9. Keep required governance checks deterministic. LLM findings are advisory. -10. Use a separate branch/worktree per implementation ticket. Each diff must - resolve to exactly one active ticket; dependency/conflict edges must be - explicit and shared contract paths require an integration ticket. -11. A developer launching an LLM from an IDE remains the operator/reviewer; - the AI keeps its own participant identity and cannot self-approve. A second - AI either owns a non-overlapping ticket or performs read-only review. +7. Keep executable source/tests/scripts outside ticket directories. +8. Run `./project/governance-check.sh` plus the stack and Docker checks before + reporting completion. +9. Serialize ticket-ID allocation before branching, then use a separate + branch/worktree per implementation ticket. Each diff must resolve to exactly + one active ticket. Shared contract paths are edited only by the declared + integration workstream; `integrationTicket` coordinates work but does not + transfer path ownership. +10. Only `IN_PROGRESS` reserves a workstream and write scope. `BACKLOG`, `PLAN` + and `BLOCKED` retain evidence without blocking another implementation; + transition back to `IN_PROGRESS` before changing source or tests. +11. Treat GitHub review as trusted only when it targets the current HEAD and + either a `User` login is in protected `trusted-reviewers` or a `Bot` login + is in the separate protected `trusted-validator-apps` input. Never trust an + arbitrary Bot review. +12. Require merge approval evidence to bind repository, PR, current HEAD, + active ticket and actor. The protected resolver creates that evidence + outside the PR checkout; repository-authored evidence is untrusted. +13. A signed attestation is trusted only after a protected verifier validates + its signature, issuer, predicate type and subject bindings. +14. Validator-agent examples use + `LLM_MODEL_VALIDATOR=openrouter/z-ai/glm-5.2`; model findings stay advisory. -Chat or Markdown approval authorizes an interactive session but is not trusted -merge evidence. Merge approval must come from an independent protected GitHub -review or signed attestation. Repository rules must require the governance -status and dismiss stale approvals after new changes. +Markdown approval is an audit note, not trusted merge authorization. Required +merge approval comes from the repository's protected review, attestation and +ruleset boundary. diff --git a/TODO.md b/TODO.md index 8c72edf..197ed27 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,11 @@ validator, trusted approval boundary, reusable governance CI, stack-specific gates and pinned adoption in `todo2code`; extend it with safe concurrent workstreams, dependency-aware intents and non-overlapping write scopes. - Current state: `IN_PROGRESS / VALIDATION` for the approved AC-11..AC-29: + Current state: `IN_PROGRESS / EDIT` for approved AC-30..AC-40: + allowlisted independent Validator App reviews bound to the exact PR head SHA + plus a non-mutating `direct-pr` strategy in `subactor/validator-agent`. + No governance, workflow, source or test implementation file has changed for + this follow-up. Earlier AC-11..AC-29 remain complete: pinned, read-only and attested `koru / code-review` PR check plus a required ruleset. `koru / code-review` and `governance / enforce` now run as required checks on `main`; the ruleset is active with no bypass actors. diff --git a/project.bat b/project.bat index 20cf487..6a9d78c 100644 --- a/project.bat +++ b/project.bat @@ -1,59 +1,33 @@ @echo off -setlocal EnableDelayedExpansion -:: Author: Tom Sapletta · https://tom.sapletta.com -:: Part of the ifURI solution. -:: Windows equivalent of project.sh +setlocal +set "REPO_ROOT=%~dp0" -cls - -if not "%T2C_SKIP_GOVERNANCE%"=="1" ( - call "%~dp0project\governance-check.bat" --actor agent - if errorlevel 1 exit /b %ERRORLEVEL% +if not exist "%REPO_ROOT%.governance\manifest.json" ( + echo GOV-MANIFEST-001: .governance\manifest.json is not installed in this target repository. 1>&2 + echo remediation: bootstrap the pinned governance package before implementation. 1>&2 + exit /b 1 ) - -set PIP_DISABLE_PIP_VERSION_CHECK=1 - -set VENV=venv -set PIP=%VENV%\Scripts\pip.exe - -if not exist "%PIP%" ( - echo Creating virtual environment... - python -m venv %VENV% +if not exist "%REPO_ROOT%project\governance-check.bat" ( + echo GOV-BOOT-001: project\governance-check.bat is missing. 1>&2 + exit /b 1 ) -"%PIP%" install --upgrade pip -q 2>nul - -"%PIP%" install regix --upgrade --quiet -"%PIP%" install prefact --upgrade --quiet -"%PIP%" install vallm --upgrade --quiet -"%PIP%" install redup --upgrade --quiet -"%PIP%" install glon --upgrade --quiet -"%PIP%" install code2logic --upgrade --quiet -"%PIP%" install code2llm --upgrade --quiet - -"%VENV%\Scripts\code2llm.exe" ./ -f all -o ./project --no-chunk --exclude "*.md" -"%VENV%\Scripts\redup.exe" scan . --format toon --output ./project --ext .mjs,.js,.php,.sh -"%VENV%\Scripts\prefact.exe" -a -e "examples/**" - -"%PIP%" install doql --upgrade --quiet -"%VENV%\Scripts\doql.exe" adopt . --format less --output app.doql.less --force - -"%PIP%" install sumd --upgrade --quiet -"%VENV%\Scripts\sumd.exe" . -"%VENV%\Scripts\sumr.exe" . - -if exist "..\goal\goal" ( - if exist "..\goal\pyproject.toml" ( - pip install -e ..\goal - "%PIP%" install -e ..\goal --quiet - ) -) else ( - pip install -U goal - "%PIP%" install goal --upgrade --quiet +call "%REPO_ROOT%project\governance-check.bat" %* +if errorlevel 1 exit /b %ERRORLEVEL% + +if not "%NEW_PROJECT_ANALYSIS_IMAGE%"=="" ( + powershell -NoProfile -Command "if ($env:NEW_PROJECT_ANALYSIS_IMAGE -notmatch '@sha256:[a-f0-9]{64}$') { exit 1 }" + if errorlevel 1 ( + echo GOV-STACK-001: NEW_PROJECT_ANALYSIS_IMAGE must be pinned by sha256 digest. 1>&2 + exit /b 1 + ) + docker info >nul 2>&1 + if errorlevel 1 ( + echo GOV-DOCKER-001: Docker engine is unavailable. 1>&2 + exit /b 1 + ) + docker run --rm --network none --mount "type=bind,src=%REPO_ROOT%,dst=/workspace" --workdir /workspace "%NEW_PROJECT_ANALYSIS_IMAGE%" + exit /b %ERRORLEVEL% ) -if exist ".\tree.bat" ( - call .\tree.bat -) else ( - echo Skipping tree snapshot: tree.bat not found. -) +exit /b 0 diff --git a/project.sh b/project.sh index 6c1715d..49025ec 100755 --- a/project.sh +++ b/project.sh @@ -1,124 +1,38 @@ #!/usr/bin/env bash -set -euo pipefail - -# Keep routine package checks quiet; this script already controls upgrades. -export PIP_DISABLE_PIP_VERSION_CHECK=1 - -PROJECT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -SEMCOD_ROOT="${SEMCOD_ROOT:-$(dirname "$PROJECT_ROOT")}" -ANALYSIS_SOURCE_MODE="${T2C_ANALYSIS_SOURCE:-tracked}" -APPLY_PREFACT="${T2C_APPLY_PREFACT:-0}" - -VENV="$PROJECT_ROOT/venv" -PIP="$VENV/bin/pip" - -cd "$PROJECT_ROOT" - -if [ "${T2C_SKIP_GOVERNANCE:-0}" != "1" ]; then - bash "$PROJECT_ROOT/project/governance-check.sh" --actor agent -fi - -if [ ! -f "$PIP" ]; then - echo "Creating virtual environment..." - python3 -m venv "$VENV" -fi - -install_project_package() { - local package="$1" - local local_package="$SEMCOD_ROOT/$package" - - if [ -f "$local_package/pyproject.toml" ]; then - echo "Installing local $package..." - "$PIP" install --editable "$local_package" --quiet - else - echo "Installing $package from PyPI..." - "$PIP" install "$package" --upgrade --quiet - fi -} +# Safe target-repository entry point for wellmanifest/new-project governance. -if [ "${T2C_SKIP_TOOL_INSTALL:-0}" != "1" ]; then - for package in regix prefact vallm redup glon goal code2logic code2llm code2docs; do - install_project_package "$package" - done -fi - -ANALYSIS_TEMP="" -cleanup_analysis_snapshot() { - if [ -n "$ANALYSIS_TEMP" ] && [ -d "$ANALYSIS_TEMP" ]; then - git worktree remove --force "$ANALYSIS_TEMP/todo2code" >/dev/null 2>&1 || true - rm -rf -- "$ANALYSIS_TEMP" - fi -} -trap cleanup_analysis_snapshot EXIT - -case "$ANALYSIS_SOURCE_MODE" in - tracked) - ANALYSIS_TEMP="$(mktemp -d /tmp/t2c-analysis.XXXXXX)" - ANALYSIS_ROOT="$ANALYSIS_TEMP/todo2code" - git worktree add --detach "$ANALYSIS_ROOT" HEAD >/dev/null - # Root-level project files and docs/README.md are generated outputs. - # Remove their tracked snapshot copies so generators cannot ingest a - # stale report and recursively embed it in the next report. - find "$ANALYSIS_ROOT/project" -maxdepth 1 -type f -delete - find "$ANALYSIS_ROOT/docs" -maxdepth 1 -type f -name README.md -delete - ;; - workspace) - ANALYSIS_ROOT="$PROJECT_ROOT" - echo "WARNING: T2C_ANALYSIS_SOURCE=workspace includes uncommitted and untracked files." >&2 - ;; - *) - echo "T2C_ANALYSIS_SOURCE must be 'tracked' or 'workspace'" >&2 - exit 2 - ;; -esac - -run_analysis_tool() { - (cd "$ANALYSIS_ROOT" && "$@") -} - -# Namespace contract: root-level files under project/ are technical analysis; -# communication lives only under recognised project// directories. -# Keep this output path for compatibility with project/analysis.toon.yaml. -# By default every generator sees a detached snapshot of HEAD, never local -# untracked files or partially edited tracked files. Set -# T2C_ANALYSIS_SOURCE=workspace only for an explicitly local, unpublished run. -#$VENV/bin/code2llm ./ -f toon,evolution,code2logic,project-yaml -o ./project --no-chunk -run_analysis_tool "$VENV/bin/code2docs" generate ./ --readme-only -node "$PROJECT_ROOT/scripts/sync-generated-readme-metadata.mjs" "$ANALYSIS_ROOT" "$ANALYSIS_ROOT/docs/README.md" -run_analysis_tool "$VENV/bin/redup" scan . --format toon --output ./project -#$VENV/bin/redup scan . --functions-only -f toon --output ./project -#$VENV/bin/vallm batch ./src --recursive --semantic --model qwen2.5-coder:7b -#$VENV/bin/vallm batch --parallel . -set +e -run_analysis_tool "$VENV/bin/python" "$PROJECT_ROOT/scripts/vallm-compatible.py" \ - batch . --recursive --no-imports --format toon --output ./project -VALLM_STATUS=$? -set -e -if [ "$VALLM_STATUS" -ne 0 ] && [ "$VALLM_STATUS" -ne 2 ]; then - echo "vallm failed to produce a validation report (exit $VALLM_STATUS)" >&2 - exit "$VALLM_STATUS" -fi +set -euo pipefail -# Generate the code2llm bundle last, so index.html embeds the fresh redup/vallm -# reports. Never analyze the generated output directory itself. -run_analysis_tool "$VENV/bin/code2llm" ./ -f all -o ./project --no-chunk \ - --exclude project docs/README.md -#$VENV/bin/code2llm report --format all # → all views -rm -f -- "$ANALYSIS_ROOT/project/analysis.json" -rm -f -- "$ANALYSIS_ROOT/project/analysis.yaml" -node "$PROJECT_ROOT/scripts/normalize-generated-analysis-roots.mjs" "$ANALYSIS_ROOT" "$ANALYSIS_ROOT" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +validator="$repo_root/project/governance-check.sh" -if [ "$ANALYSIS_ROOT" != "$PROJECT_ROOT" ]; then - while IFS= read -r -d '' generated; do - cp -- "$generated" "$PROJECT_ROOT/project/$(basename "$generated")" - done < <(find "$ANALYSIS_ROOT/project" -maxdepth 1 -type f -print0) - cp -- "$ANALYSIS_ROOT/docs/README.md" "$PROJECT_ROOT/docs/README.md" +if [[ -x "$validator" && -f "$repo_root/.governance/manifest.json" ]]; then + "$validator" "$@" +elif [[ ! -f "$repo_root/.governance/manifest.json" ]]; then + echo "GOV-MANIFEST-001: .governance/manifest.json is not installed in this target repository." >&2 + echo " remediation: bootstrap the pinned governance package before implementation." >&2 + exit 1 +else + echo "GOV-BOOT-001: project/governance-check.sh is missing or not executable." >&2 + echo " remediation: restore the wrapper from the pinned governance package." >&2 + exit 1 fi -node scripts/verify-generated-analysis.mjs "$PROJECT_ROOT" - -if [ "$APPLY_PREFACT" = "1" ]; then - "$VENV/bin/prefact" -a -e "examples/**" -else - echo "Skipping source refactoring; set T2C_APPLY_PREFACT=1 to apply prefact changes." +# Optional analysis tools must be supplied as an explicitly pinned image. +# The governance gate above always runs first and no package is installed on the host. +if [[ -n "${NEW_PROJECT_ANALYSIS_IMAGE:-}" ]]; then + if [[ ! "$NEW_PROJECT_ANALYSIS_IMAGE" =~ @sha256:[a-f0-9]{64}$ ]]; then + echo "GOV-STACK-001: NEW_PROJECT_ANALYSIS_IMAGE must be pinned by sha256 digest." >&2 + echo " remediation: use registry/image@sha256:<64 lowercase hex characters>." >&2 + exit 1 + fi + command -v docker >/dev/null 2>&1 || { + echo "GOV-DOCKER-001: docker command is unavailable." >&2 + exit 1 + } + docker info >/dev/null + docker run --rm --network none \ + --mount "type=bind,src=$repo_root,dst=/workspace" \ + --workdir /workspace \ + "$NEW_PROJECT_ANALYSIS_IMAGE" fi diff --git a/project/new-ticket.sh b/project/new-ticket.sh index 4d78acb..f01ca11 100755 --- a/project/new-ticket.sh +++ b/project/new-ticket.sh @@ -6,7 +6,7 @@ set -euo pipefail TITLE="New Task Ticket" USERS="" AGENT="antigravity" -WORKSTREAM="unresolved" +WORKSTREAM="" FORCE_NEW=false usage() { @@ -15,7 +15,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 Declared workstream (for example runtime or sdk) + -w, --workstream ID Required workstream declared in the governance manifest -u, --users IDS Compatibility input only; human files are not created --force-new Create a new ticket despite an unfinished ticket -h, --help Show this help @@ -82,15 +82,20 @@ if [[ ! "$AGENT" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then exit 2 fi +if [[ -z "$WORKSTREAM" ]]; then + echo "Workstream is required; choose an id declared in .governance/manifest.json" >&2 + exit 2 +fi + WORKSTREAM="$(printf '%s' "$WORKSTREAM" | tr '[:upper:]' '[:lower:]')" if [[ ! "$WORKSTREAM" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then echo "Workstream id must match [a-z0-9][a-z0-9-]*" >&2 exit 2 fi -is_closed_ticket() { +is_active_ticket() { local readme="$1/README.md" - [[ -f "$readme" ]] && grep -Eiq '^-[[:space:]]+\*\*Status\*\*:[[:space:]]*(DONE|CANCELLED)([[:space:]]|$)' "$readme" + [[ -f "$readme" ]] && grep -Eiq '^-[[:space:]]+\*\*Status\*\*:[[:space:]]*IN_PROGRESS([[:space:]]|$)' "$readme" } highest=0 @@ -102,7 +107,7 @@ if [[ -d project ]]; then [[ "$number" =~ ^[0-9]+$ ]] || continue decimal=$((10#$number)) (( decimal > highest )) && highest=$decimal - if ! is_closed_ticket "$dir"; then + if is_active_ticket "$dir"; then active_workstream="$(sed -nE 's/^[[:space:]]*"workstream"[[:space:]]*:[[:space:]]*"([a-z0-9-]+)".*/\1/p' "$dir/intent.json" 2>/dev/null | head -n 1)" if [[ -z "$active_workstream" || "$active_workstream" == "unresolved" || "$WORKSTREAM" == "unresolved" || "$active_workstream" == "$WORKSTREAM" ]]; then conflicting_ticket="$dir" diff --git a/project/ticket-018/README.md b/project/ticket-018/README.md index d2105b4..7f7249e 100644 --- a/project/ticket-018/README.md +++ b/project/ticket-018/README.md @@ -87,10 +87,10 @@ the existing organization-level `OPENROUTER_API_KEY` secret. The workflow will never use `pull_request_target`, check out untrusted code with a write-capable token, modify source, auto-fix, commit, push or submit a -GitHub `APPROVE` review. A missing secret or semantic-provider failure is an -explicit non-passing outcome rather than a silent deterministic fallback. -Forked pull requests therefore require a trusted maintainer rerun in a safe -context instead of receiving organization secrets. +GitHub `APPROVE` review. A missing secret or semantic-provider failure is +recorded explicitly in the attested advisory report. It cannot decide the +required merge gate; deterministic `verify` and Java checks remain separate +required checks. Forked pull requests never receive organization secrets. The machine-readable report will be bound to repository, base SHA, head SHA, tool versions and verdict, uploaded as a CI artifact and covered by a GitHub @@ -99,6 +99,45 @@ governance check and `koru / code-review`; the Koru attestation is independent read-only review evidence, not evidence that the implementation author or this agent self-approved. +## Planned autonomous Validator approval extension + +The user authorizes a dedicated `subactor/validator-agent` identity to review +and approve pull requests after deterministic checks and a bounded semantic +review. This is an independent reviewer, not the implementation agent and not +an arbitrary GitHub bot. + +The coordinated implementation is bounded as follows: + +- `todo2code` will version an allowlist of trusted Validator GitHub App review + identities. CI will accept an App approval only when its login and account + type match the allowlist, the reviewer differs from the PR author, the review + is `APPROVED`, and its `commit_id` equals the current PR head SHA. +- Human `User` approvals remain supported. Unknown bots, stale approvals, + dismissed reviews, review authors matching the PR author and mutable + Markdown claims remain rejected. +- `validator-agent` will add an explicit `direct-pr` strategy for a repository, + PR number and expected head SHA. Repository and base-branch allowlists are + mandatory; the existing `if-uri/Agents #2` project-queue strategy remains + unchanged. +- Direct validation is read-only with respect to the reviewed branch: it does + not edit `VERSION`, `CHANGELOG.md`, repair TODOs, Issues or Project fields. + Its only successful mutation is one GitHub `APPROVE` review from the + dedicated Validator identity; rejection uses `REQUEST_CHANGES`. +- The direct strategy verifies the exact head twice, evaluates unsafe diff + markers, requires configured hosted checks other than the circular + `governance / enforce` approval gate, and performs the bounded OpenRouter + review with `openrouter/z-ai/glm-5.2`. +- Workflow dispatch will require explicit `strategy=direct-pr`, repository, + PR and expected SHA inputs. The GitHub App token is scoped to the selected + owner/repository and merge remains disabled. + +Planned `todo2code` paths are already covered by ticket-018: +`.governance/**`, `.github/workflows/ci.yml`, `AGENTS.md`, `TODO.md` and this +ticket. Planned `validator-agent` paths are `.github/workflows/validator.yml`, +`src/validator_agent/{cli,direct_validation,github}.py`, focused tests and the +existing README/runbook/permissions documentation. No application source in +`todo2code` and no unrelated dirty `validator-agent` file is in scope. + ## Acceptance criteria - [x] AC-01: A human approves this understanding and execution checklist before @@ -157,9 +196,9 @@ agent self-approved. - [x] AC-20: Koru 0.1.444 runs exactly one read-only Vallm 0.1.94 review round over changed supported source files; auto-fix, commit, push and mutable dependency versions are absent. -- [x] AC-21: Deterministic syntax/complexity/security checks and semantic - LLM-as-judge review fail closed on findings, missing credentials, - malformed output or provider failure, with no secret value in logs. +- [x] AC-21: Deterministic project verification fails closed independently; + Koru/Vallm semantic findings, missing credentials and provider failures + remain explicit advisory evidence, with no secret value in logs. - [x] AC-22: The structured report records repository, base/head SHA, selected files, tool/model versions and verdict, is uploaded with fixed retention, and receives GitHub artifact provenance attestation. @@ -183,6 +222,40 @@ agent self-approved. - [x] AC-29: The runtime workstream owns its Python runtime adapter test so the canonical `0.5.2` release assertion can be repaired without cross-stream scope laundering. +- [x] AC-30: A human approves AC-30..AC-40 and the exact cross-repository paths + before governance, workflow, source or test implementation changes. +- [x] AC-31: The manifest/schema version a narrow trusted Validator review + actor allowlist without treating every GitHub bot as trusted. +- [x] AC-32: Pull-request CI accepts an allowlisted independent Validator App + approval only for the exact current head SHA and retains existing human + `User` approval behavior. +- [x] AC-33: Deterministic fixtures reject unknown bots, stale/dismissed + reviews, same-author reviews and malformed allowlist entries. +- [x] AC-34: `validator-agent` exposes an explicit direct-PR strategy bound to + repository, PR number, allowed base branch and expected head SHA while + preserving the existing Project-queue strategy. +- [x] AC-35: Direct validation never commits release metadata, edits the PR + branch, mutates Issues/Projects or merges; its verdict mutation is limited + to `APPROVE` or `REQUEST_CHANGES` from the dedicated identity. +- [x] AC-36: The direct strategy checks the exact diff, unsafe markers, required + hosted checks and head stability, excluding only the documented circular + approval gate from its prerequisite set. +- [x] AC-37: The semantic review uses `openrouter/z-ai/glm-5.2`, preserves cost, + timeout and schema limits, and cannot become the required merge decision. +- [x] AC-38: Workflow dispatch requires explicit direct strategy inputs and + creates a repository-scoped Validator App token; arbitrary repositories + and mutable/unpinned heads are rejected. +- [x] AC-39: Focused negative/positive tests, both complete repository suites, + governance, Java, gold, SDK examples and Docker smoke pass. +- [ ] AC-40: After a separately trusted bootstrap review merges the policy, + the real Validator App reviews PR #13 at its exact SHA and the rerun + proves `governance / enforce` accepts that independent agent evidence. + +Central adoption for AC-31..AC-33 is pinned to +`wellmanifest/new-project@78b365272b5b258931f9a66d7124122ec19d7814`. +The caller passes `ifuri-validator-agent[bot]` through the App-only allowlist; +the reusable workflow resolves the ticket and writes current-event approval +evidence under `runner.temp`, outside the pull-request checkout. ## Participants diff --git a/project/ticket-018/ai-codex-logs.txt b/project/ticket-018/ai-codex-logs.txt index ee2475d..7bcd4f8 100644 --- a/project/ticket-018/ai-codex-logs.txt +++ b/project/ticket-018/ai-codex-logs.txt @@ -273,3 +273,86 @@ $ make e2e-full PASS: cargo fetch --locked; 338 tests passed, 0 failed, 0 skipped gold v1/v2: 100% precision and recall; repeated-run stability PASS project2.sh: NOT RUN +2026-08-04 AC-30..AC-40 planned: allowlisted Validator App review plus direct-pr strategy +2026-08-04 deployed validator model variable set to openrouter/z-ai/glm-5.2; no live review dispatched +2026-08-04 state VALIDATION -> WAIT_FOR_APPROVAL; no executable implementation files changed +2026-08-04 user approval: "zatwierdzam AC-30..AC-40 ticket-018"; state WAIT_FOR_APPROVAL -> EDIT +2026-08-04 AC-31..AC-39 IMPLEMENTATION AND VALIDATION +$ node --test .governance/resolve-approval.test.mjs +PASS: 5/5 approval trust-boundary fixtures +$ make governance +GOV-PASS: 0 errors, 0 warnings +$ npm run verify +PASS: 342 tests; 341 pass, 0 fail, 1 local JDK skip +$ make smoke && make examples-check && make docker-smoke +PASS: smoke; five SDK languages; Docker smoke +$ npm run evaluate:gold +PASS: gold v2 100% precision/recall and repeated-run stability +$ docker compose -f compose.e2e.yml run --rm --no-deps e2e-full +PASS: 342 tests, 0 failed, 0 skipped; JDK 17 Java adapter PASS; gold v1/v2, +MCP, A2A, CLI smoke and examples PASS +$ SKILLS_AGENT_PROCESS_ROOT=../skills-agent/SKILLS python -m pytest -q +validator-agent PASS: 96 tests +state EDIT -> VALIDATION; AC-40 remains open for separately trusted bootstrap +merge and real Validator App review of todo2code PR #13 +2026-08-04 KORU PR #13 BLOCKER AUDIT +run 30912643992 artifact review.json: reject +root infrastructure error on all four TypeScript files: Vallm --regression +invoked Python pytest, which was unavailable and is not the project test runner +correction: remove --regression from Koru only; retain hosted npm verify + JDK +checks and Koru syntax/complexity/security/semantic gates; semantic model GLM 5.2 +2026-08-04 KORU TRUST-BOUNDARY CORRECTION +policy basis: P-CORE-016 and P-AGENT-007; required decisions cannot use LLM output +report: t2c.koru-code-review/v2 with exact repository/base/head/model bindings +GLM findings/provider availability: advisory-only +provider bounds: 8192 tokens, 420 seconds, zero retries; job ceiling 10 minutes +required deterministic checks remain governance / enforce, verify and Java adapter +2026-08-04 CENTRAL 0.9.0 ADOPTION +source revision: 78b365272b5b258931f9a66d7124122ec19d7814 +reusable workflow uses and standard-ref: identical immutable SHA +trusted Validator App input: ifuri-validator-agent[bot] +approval evidence location: runner.temp outside pull-request checkout +bindings: repository, pullRequest, headSha, ticket, actor +legacy checkout-owned resolver: detached from CI; retained for generated-analysis consistency +historical trusted App reviews resolve the exact login as +ifuri-validator-agent[bot]; corrected the manifest and workflow default from +the earlier unverified if-uri-validator-agent[bot] spelling +2026-08-04 PR #14 HOSTED VALIDATION +head: 646cea89582633f99ce0ef549811771023ee25de +verify: PASS; Java adapter: PASS; koru / code-review v2: PASS +governance: expected GOV-APPROVAL-001/002 only, pending independent review +validator-agent run: 30918035304 at main 431ba7936d759f45da9670eb80010b2dfc7074f2 +validator tests: PASS +repository-scoped App token: BLOCKED before validation +GitHub API: GET /repos/semcod/todo2code/installation -> 404 Not Found +required external action: install ifuri-validator-agent on semcod/todo2code only +state: IN_PROGRESS -> BLOCKED; reservation released until installation +2026-08-04 APP INSTALLATION RECHECK AFTER USER APPROVAL +validator-agent dry-run: 30918421022 +tests: PASS; no review or target mutation requested +repository-scoped App token: still 404 Not Found +conclusion: chat approval authorizes continuation but cannot create a GitHub App +installation; interactive installation remains required at the App settings boundary +2026-08-04 USER CONFIRMED APP INSTALLATION +target: semcod/todo2code +transition: BLOCKED -> IN_PROGRESS / VALIDATION +next boundary: publish a new head, require exact-head hosted checks, then live review +2026-08-04 CENTRAL PUSH-EVENT FOLLOW-UP +old standard: 78b365272b5b258931f9a66d7124122ec19d7814 +new standard: d082373f314191dba794aba58aca2d4475ea497a +change: workflow-owned .new-project-standard/ excluded through .git/info/exclude +security property: tracked target paths remain visible to governance diff checks +2026-08-04 LIVE VALIDATION AFTER REPORTED INSTALLATION +head: 4ab9c2544798ff851ee3235036e8ecb9fc24252c +push governance: PASS; verify: PASS; Java: PASS; Koru v2: PASS +validator-agent run: 30921738666 +App JWT repository lookup: GET /repos/semcod/todo2code/installation -> 404 +authenticated org audit: GET /orgs/semcod/installations -> total_count=0 +result: no review emitted; BLOCKED and reservation released +2026-08-04 SEMCOD APP INSTALLATION CONFIRMED +organization installation: 151227156 +App slug: ifuri-validator-agent +repository selection: all +transition: BLOCKED -> IN_PROGRESS / VALIDATION +next boundary: publish a fresh ticket-only head, pass deterministic hosted +checks, then request an exact-head direct-pr review from the installed App diff --git a/project/ticket-018/ai-codex.md b/project/ticket-018/ai-codex.md index d3c8518..de1241d 100644 --- a/project/ticket-018/ai-codex.md +++ b/project/ticket-018/ai-codex.md @@ -108,6 +108,19 @@ Current verified baseline: repaired aggregate. 25. Route `test/python-runtime*` to the runtime workstream after full verification exposes its stale release assertion. +26. Return to `WAIT_FOR_APPROVAL` for AC-30..AC-40 before changing approval + policy, workflows, Validator source or tests. +27. Add a versioned, narrow allowlist for independent Validator App review + actors and bind accepted reviews to the exact current PR head SHA. +28. Add negative governance fixtures for arbitrary bots, stale/dismissed + reviews and same-author evidence while preserving human review behavior. +29. Add `validator-agent` strategy `direct-pr` with explicit repository, PR, + base and SHA boundaries, hosted-check evidence and no branch metadata + mutation or merge. +30. Keep the existing Project-queue strategy unchanged and select the strategy + explicitly at workflow dispatch. +31. Validate locally, publish scoped PRs, obtain the required bootstrap review, + then exercise the dedicated Validator identity against todo2code PR #13. ## Actual changes @@ -165,6 +178,33 @@ Current verified baseline: and Koru status checks, mandatory pull requests, stale-evidence dismissal and force-push/deletion prevention. It remains disabled solely for the final bootstrap evidence merge and will be activated afterward. +- Planned only AC-30..AC-40 for independent Validator App approval and the + `direct-pr` strategy. No governance, workflow, source or test implementation + file was changed in this planning phase. +- Changed the deployed `subactor/validator-agent` GitHub Actions variable from + Gemini 3.1 Pro Preview to `openrouter/z-ai/glm-5.2`; no validation run was + dispatched and no secret value was read. +- Implemented AC-31..AC-39 after the explicit approval: exact current-head + trust resolution in todo2code and a repository/PR/base/SHA-bound direct + strategy in Validator. The direct path cannot edit a branch, release + metadata, Issues, Projects, or merge state. +- Full local and container validation passes, including Validator 96/96, + todo2code full E2E with JDK 17 at 342/342, gold v1/v2, SDK examples, + governance and Docker smoke. AC-40 remains an external bootstrap sequence. +- Audited Koru's failed PR #13 artifact and found a deterministic tool mismatch: + the Python-only Vallm regression plugin invoked missing `pytest` for every + TypeScript file. Removed that plugin from Koru while retaining the real npm + verify/JDK checks and syntax, complexity, security and GLM 5.2 semantics. +- Corrected the trust boundary after policy 0.9.0 review: GLM findings and + provider availability are recorded in `t2c.koru-code-review/v2` as advisory. + The required merge decision remains in deterministic governance, verify and + Java checks; the Koru job enforces exact report bindings, not an LLM verdict. +- Adopted central standard 0.9.0 at immutable revision + `78b365272b5b258931f9a66d7124122ec19d7814`. CI now calls that exact reusable + workflow and passes only the observed `ifuri-validator-agent[bot]` App login. + Approval evidence is generated in `runner.temp` and bound to repository, PR, + current head, ticket and actor. The earlier checkout-owned resolver is detached + from CI and retained only until the tracked generated-analysis index is refreshed. ## Blockers @@ -174,19 +214,30 @@ Current verified baseline: - `GOV-SCOPE-001`: the same commit contains eight implementation/generated paths not allowed by ticket-018. They must be routed to their actual ticket, not retroactively claimed here. -- Central `new-project` 0.7.0 is uncommitted/unpublished, so no honest immutable - reusable-workflow SHA exists yet. +- Central standard 0.9.0 is published at immutable commit + `78b365272b5b258931f9a66d7124122ec19d7814`; its PR #2 is green and still + awaits an independent merge review. +- Live Validator run `30918035304` proved the dedicated App credentials are + valid but the App has no installation for `semcod/todo2code`; repository- + scoped token creation failed closed with GitHub API 404 before validation. - The earlier AC-17 Rust lock failure no longer reproduces on current HEAD: locked Cargo fetch and full Docker E2E pass without a governance-owned SDK edit. ## Approval boundary -- Current state: `IN_PROGRESS / VALIDATION`. AC-11..AC-29 and application/full - Docker validation pass; the earlier publication/external blockers remain. +- Current state: `IN_PROGRESS / VALIDATION`. GitHub now reports installation + `151227156` for App `ifuri-validator-agent` in organization `semcod`, with + repository selection `all`. A fresh ticket-only HEAD will bind the hosted + checks and Validator review to evidence created after this installation. - Required response from: `unresolved:human`. - The user explicitly approved AC-18..AC-25 in chat. This authorizes the implementation workflow but is not itself merge-time review evidence. - The current follow-up is planned as AC-26..AC-28 in `IN_PROGRESS / VALIDATION`. The user's `kontynuuj` response authorizes this exact interactive implementation scope, but remains insufficient merge evidence. +- Current follow-up state: `IN_PROGRESS / WAIT_FOR_APPROVAL` for AC-30..AC-40. + The user's request authorizes planning and policy evolution; executable edits + begin only after explicit approval of this exact allowlist/direct-PR design. +- The user explicitly approved AC-30..AC-40 on 2026-08-04. Current state: + `IN_PROGRESS / VALIDATION`; protected merge evidence remains independent. diff --git a/project/ticket-018/changelog.md b/project/ticket-018/changelog.md index 156af06..4f4d517 100644 --- a/project/ticket-018/changelog.md +++ b/project/ticket-018/changelog.md @@ -1,5 +1,67 @@ # Ticket Changelog (ticket-018) +## [0.6.0] - 2026-08-04 + +- Added a versioned, exact Validator GitHub App allowlist and current-head + approval resolver while preserving independent human `User` reviews. +- Added deterministic rejection fixtures for unknown bots, stale/dismissed + reviews, self-review and malformed or duplicate allowlist entries. +- Added the non-mutating `direct-pr` strategy to `validator-agent`, pinned it to + explicit repository/PR/base/SHA inputs and `openrouter/z-ai/glm-5.2`, and + scoped its workflow App token to one repository. +- Verified 96 Validator tests, 342 full Docker E2E tests with JDK 17, both gold + datasets at 100%, all SDK examples, governance, smoke and Docker smoke. +- Entered `VALIDATION`; AC-40 remains open until the policy receives a separate + trusted bootstrap review and the real App reviews todo2code PR #13. +- Removed Vallm's Python-only `--regression` plugin from the TypeScript Koru + review after live evidence showed it called missing `pytest` for every TS + file. Regression remains strictly enforced by the separate `verify` and Java + checks; Koru retains syntax, complexity, security and GLM 5.2 semantic review. +- Replaced the LLM-derived Koru gate verdict with commit-bound advisory evidence + (`t2c.koru-code-review/v2`). Added a 420-second/8192-token/zero-retry provider + boundary and TypeScript parser normalization; deterministic CI remains the + only required decision source. +- Corrected the allowlisted actor to the observed GitHub review identity + `ifuri-validator-agent[bot]` from existing Validator App approvals. +- Bound trusted App evidence to the exact active `ticket-NNN` and safe + correlation ID recorded in the current-head review body; human review + behavior remains unchanged. +- Adopted central standard 0.9.0 at immutable commit `78b3652`, including the + reusable protected resolver and ephemeral current-event approval evidence. +- Verified PR #14 remotely: Koru v2, Node/Docker verification and required Java + passed. Live Validator run `30918035304` stopped before review because the App + is not installed in `semcod/todo2code`; ticket state moved to `BLOCKED` and + releases its reservation until that external installation is completed. +- Rechecked installation in non-mutating run `30918421022` after user approval; + token creation still returned 404, confirming that the remaining step is the + interactive GitHub App installation rather than a code or secret defect. +- Recorded the user's completed App installation and returned ticket-018 to + `IN_PROGRESS` before producing the new current-head validation request. +- Advanced the immutable standard pin to `d082373` after its push-event fix + excluded only the injected standard checkout through `.git/info/exclude`. +- Confirmed the central push gate, Node/Docker, Java and Koru checks on exact + head `4ab9c254`. Live run `30921738666` still received installation 404, and + the organization API reports `semcod` installation count zero; returned the + ticket to `BLOCKED` without emitting a review. +- Confirmed the new `semcod` installation `151227156` for + `ifuri-validator-agent` with repository selection `all`; resumed + `IN_PROGRESS / VALIDATION` before creating fresh current-head evidence. + +## [0.5.0] - 2026-08-04 + +- Planned AC-30..AC-40 for allowlisted independent Validator App approvals + bound to the exact PR head SHA. +- Planned a non-mutating `direct-pr` validator strategy alongside the existing + Project-queue strategy. +- Kept arbitrary bots, stale reviews, self-review, metadata commits and merge + authority outside the trusted path. +- Updated the deployed Validator model variable to + `openrouter/z-ai/glm-5.2` without dispatching a live review. +- Stopped at `IN_PROGRESS / WAIT_FOR_APPROVAL`; no executable implementation + file changed. +- Recorded explicit approval of AC-30..AC-40 and entered `EDIT` before any + executable change. + ## [0.4.0] - 2026-08-04 - Planned AC-26..AC-28 to assign and normalize exactly three tracked generated