Compare commits

...
Author SHA1 Message Date
sysadminandClaude Opus 4.8 7d2214b17b feat: require pagination proof for reconciliation inventory claims (Closes #308)
Add assess_reconciliation_inventory_proof to review_proofs:
already-landed reconciliation reports may not claim a complete queue
scan or that all already-landed PRs were found unless the report
carries the pagination proof fields (Requested page size, Pages
fetched, Returned PR count per page, Final page proof) and the
per-page gitea_list_prs pagination metadata proves the final page was
reached — inventory_complete, is_final_page with has_more=false, or a
returned count below the requested page size. Declared page counts and
per-page counts must match the evidence. Reports without a
completeness claim pass without proof. Fails closed on missing
evidence or malformed page metadata. Tests cover first page below
limit, first page exactly at the page limit, multi-page traversal to
the final page, missing pagination metadata, missing report fields,
page-count and per-page-count mismatches, and the no-claim case.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:12:15 -04:00
sysadmin 5e023dcc97 Merge pull request 'feat: verify linked-issue consistency for the selected PR (Closes #314)' (#360) from feat/issue-314-linked-issue-consistency into master 2026-07-07 04:45:10 -05:00
sysadmin f87101ccaa Merge pull request 'feat: require live blocker proof before skipping earlier PRs (Closes #318)' (#355) from feat/issue-318-prior-blocker-skip-proof into master 2026-07-07 04:41:56 -05:00
sysadmin fa0cb12464 Merge pull request 'feat: block reviewer local Gitea fallbacks during normal review (Closes #324)' (#350) from feat/issue-324-reviewer-no-fallback into master 2026-07-07 04:37:12 -05:00
sysadmin f1c7054871 fix: resolve conflicts for PR #360 2026-07-07 05:35:13 -04:00
sysadmin 4204503b41 fix: resolve conflicts for PR #355 2026-07-07 05:35:13 -04:00
sysadmin f2d82a93ed fix: resolve conflicts for PR #350 2026-07-07 05:33:47 -04:00
sysadmin e1dabd1b0f Merge pull request 'feat: reject legacy Workspace mutations field in reviewer handoffs (Closes #320)' (#357) from feat/issue-320-remove-workspace-mutations into master 2026-07-07 04:33:31 -05:00
sysadmin 4d3e90e7c5 Merge pull request 'feat: enforce queue ordering proof in reviewer reports (Closes #321)' (#351) from feat/issue-321-queue-ordering-proof into master 2026-07-07 04:31:14 -05:00
sysadmin 634d8a17aa Merge pull request 'feat: composable final-report validator for reviewer and reconciliation handoffs (Closes #327)' (#348) from feat/issue-327-final-report-validator-rules into master 2026-07-07 04:29:04 -05:00
sysadmin bc227a9a6d feat: block reviewer local Gitea fallbacks during normal review (Closes #324) 2026-07-07 05:27:24 -04:00
sysadmin 2cf0ad5908 feat: add composable final-report validator for reviewer and reconciliation handoffs (Closes #327) 2026-07-07 05:27:24 -04:00
sysadmin 077cedc0c9 feat: enforce queue ordering proof in reviewer reports (Closes #321) 2026-07-07 05:27:18 -04:00
sysadmin c83c18e52a Merge pull request 'feat: enforce proof wording for pagination and live claims (Closes #330)' (#346) from feat/issue-330-proof-wording-enforcement into master 2026-07-07 04:26:44 -05:00
sysadmin 1666e55e60 Merge pull request 'feat: reject workspace-none claims after worktree mutations (Closes #313)' (#354) from feat/issue-313-worktree-mutation-consistency into master 2026-07-07 04:23:54 -05:00
sysadminandClaude Opus 4.8 1e6bd34d6f feat: verify linked-issue consistency for the selected PR (Closes #314)
Add assess_linked_issue_consistency to review_proofs: reviewer final
reports must report a "Linked issue status" that matches the issue(s)
the selected PR was live-verified to link this session. Without live
proof the field may only say "not verified in this session"; with
proof, every linked issue must be named, and any other "Issue #N"
mention anywhere in the report fails as stale leakage from a previous
PR (the PR #280 / Issue #260-vs-#275 incident). Missing field fails
closed. Tests cover correct linked issue, stale previous issue in both
the status field and diagnostics, missing proof, unverified wording,
multiple linked issues, and PR-number mentions not being mistaken for
issue mentions.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:22:08 -04:00
sysadmin e9fdb356dd fix: resolve conflicts for issue 330 (master refresh) 2026-07-07 05:21:43 -04:00
sysadmin 897d256f98 Merge pull request 'feat: add queue-status report verifier for reviewer handoffs (Closes #339)' (#347) from feat/issue-339-queue-status-report-verifier into master 2026-07-07 04:20:09 -05:00
sysadmin 240e7adf46 fix: resolve conflicts for issue 339 2026-07-07 05:18:29 -04:00
sysadmin deb228d629 Merge pull request 'feat: require conflict proof for skipped non-mergeable PRs (Closes #322)' (#353) from feat/issue-322-mergeability-skip-proof into master 2026-07-07 04:17:09 -05:00
sysadminandClaude Opus 4.8 94b022b24d fix: resolve conflicts for issue 330
Merge prgs/master into feat/issue-330-proof-wording-enforcement and keep
both assess_proof_wording (#330) and git ref mutation reporting (#297).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:16:57 -04:00
sysadmin 6bf0210128 Merge pull request 'feat: enforce branches-only baseline validation in reviewer reports (Closes #325)' (#349) from feat/issue-325-baseline-worktree-validation into master 2026-07-07 04:13:22 -05:00
sysadminandClaude Opus 4.8 56661cd0a7 feat: require live blocker proof before skipping earlier PRs (Closes #318)
Add assess_prior_blocker_skip_proof to validate reviewer reports document
live REQUEST_CHANGES blocker proof for skipped earlier open PRs, reject
stale-memory skips when head changed after blocker, and require
BLOCKER_STATUS_UNVERIFIED when review feedback cannot be fetched.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:12:01 -04:00
sysadminandClaude Opus 4.8 c2bba27b86 feat: reject legacy Workspace mutations field in reviewer handoffs (Closes #320)
Reviewer controller handoffs must now report the precise mutation
categories (File edits by reviewer, Worktree/index mutations, Git ref
mutations, MCP/Gitea mutations, Review mutations, Merge mutations,
Cleanup mutations, External-state mutations, Read-only diagnostics)
instead of the ambiguous legacy "Workspace mutations" field.

- assess_controller_handoff(role="review") no longer requires
  "Workspace mutations" and fails closed when any legacy
  "Workspace mutations:" line is present, regardless of value.
- New HANDOFF_REVIEW_MUTATION_FIELDS makes the nine precise categories
  required fields for review-role handoffs, matching the canonical
  schemas/review-merge-final-report.md field set.
- Tests cover approval, request-changes, merge, worktree cleanup,
  fetch-only, and blocked handoffs, plus rejection of
  "Workspace mutations: none" and non-none values.
- Author/create-issue/inventory roles keep their existing contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:11:05 -04:00
sysadminandClaude Opus 4.8 9fbe556466 feat: reject workspace-none claims after worktree mutations (Closes #313)
Add assess_workspace_mutation_consistency to review_proofs: final
reports fail validation when they claim "Workspace mutations: none"
while worktree mutations (reset --hard, checkout, clean, worktree
add/remove, stash, merge, rebase, ...) occurred — either observed in
the workflow command log or self-reported in the report's own
"Worktree mutations" field. Observed worktree and git ref mutations
must be reported under their precise category fields; reports pass
when they say "File edits by reviewer: none" alongside a non-none
"Worktree mutations" entry. Tests cover reset, checkout, worktree add,
clean, fetch-only, and no-op cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:09:44 -04:00
sysadmin 407483f6e0 Merge pull request 'feat: enforce session hard-stop after terminal review mutation (Closes #332)' (#345) from feat/issue-332-terminal-review-hard-stop into master 2026-07-07 04:09:19 -05:00
sysadminandClaude Opus 4.8 cf1cedf5e3 feat: require conflict proof for skipped non-mergeable PRs (Closes #322)
Add assess_non_mergeable_skip_proof to validate reviewer reports document
head SHA, mergeability result, conflict proof command, conflicting files,
and head-change status for each skipped non-mergeable PR, or classify
MERGEABILITY_UNVERIFIED when proof cannot be retrieved.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:08:53 -04:00
sysadmin d7d2f92286 Merge pull request 'feat(reports): classify git fetch as a git ref mutation (closes #297)' (#344) from feat/issue-297-fetch-ref-mutation into master 2026-07-07 04:07:11 -05:00
sysadmin cbae9a3ecd Merge pull request 'docs: pin GlitchTip filing orchestrator contract (Closes #153)' (#343) from feat/issue-153-glitchtip-filing-orchestrator into master 2026-07-07 04:05:22 -05:00
sysadmin f92d1a29c2 Merge pull request 'Add pagination metadata to gitea_list_prs' (#342) from feat/issue-340-list-prs-pagination-metadata into master 2026-07-07 04:03:30 -05:00
sysadminandClaude Opus 4.8 a47083b9b8 feat: enforce branches-only baseline validation in reviewer reports (Closes #325)
Add assess_reviewer_baseline_validation_proof to block test-suite runs in
the main checkout and require complete baseline worktree proof when reports
claim pre-existing master failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:02:07 -04:00
sysadmin 0ae39c231d Merge pull request 'feat: add create-issue final-report verifier (Closes #337)' (#341) from feat/issue-337-create-issue-verifier into master 2026-07-07 03:59:00 -05:00
sysadmin 6c653ce32a feat: add queue-status report verifier for reviewer handoffs (#339)
Add assess_queue_status_report and assess_proof_wording to reject
contradictory queue-status-only reports: passed gates without a selected
PR, prior diagnostic conflict proof, pagination assumptions, and
blocker contradictions. Wire into build_final_report and document the
queue-status-only schema path.

Closes #339
2026-07-07 04:57:50 -04:00
sysadmin 5e145957a5 Merge pull request 'feat: extract review-merge-pr workflow from monolithic LLM skill' (#335) from feat/issue-334-review-merge-workflow-extract into master 2026-07-07 03:57:18 -05:00
sysadmin 46703eac3f feat: enforce proof wording for pagination and live claims (#330)
Add assess_proof_wording to reject unsupported phrases such as
inventory complete, live proof, same as master, and file edits none
unless matching evidence is present. Wire into build_final_report and
add doc-contract tests.

Closes #330
2026-07-07 04:46:48 -04:00
sysadmin 6e024db9eb Merge remote-tracking branch 'prgs/master' into feat/issue-337-create-issue-verifier 2026-07-07 04:44:03 -04:00
sysadmin fd6f7ff22c Merge pull request 'docs: add subagent tool-budget guardrails for MCP tasks (Closes #259)' (#284) from docs/issue-259-subagent-tool-budget into master 2026-07-07 03:43:47 -05:00
sysadmin a06af6f0e3 Merge pull request 'feat: merged PR cleanup reconciliation report (Closes #269)' (#282) from feat/issue-269-merged-pr-cleanup-reconcile into master 2026-07-07 03:39:48 -05:00
sysadmin 72c20ee1e1 Merge remote-tracking branch 'prgs/master' into feat/issue-334-review-merge-workflow-extract 2026-07-07 04:38:09 -04:00
sysadmin eb6f006a31 Merge pull request 'fix: recompute infra_stop live on capability resolve (Closes #285)' (#286) from feat/issue-285-live-infra-stop-recompute into master 2026-07-07 03:37:22 -05:00
sysadminandClaude Opus 4.8 3a490521e4 Merge prgs/master into feat/issue-269-merged-pr-cleanup-reconcile
Resolve task_capability_map.py conflict by keeping all three capability
entries: commit_files and gitea_commit_files (gitea.repo.commit, #262,
from master) and reconcile_merged_cleanups (gitea.read, #269, this
branch). Capability boundaries unchanged — reconciliation reporting stays
read-only; no close/merge capability broadened.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 04:37:11 -04:00
sysadmin 3c50db61eb merge: integrate remote branch history 2026-07-07 04:35:59 -04:00
sysadmin edf26410a0 merge: resolve master conflicts for #259 subagent tool-budget docs
Integrate split-workflow router SKILL.md (#333) with subagent tool-budget
guardrails (#259). Keep shell spawn hard-stop (#258) and subagent
delegation (#266) operator-guide rules alongside subagent_tool_budget.
2026-07-07 04:35:46 -04:00
sysadmin e1fbf0112a test: mock assess_infra_stop in route_task_session merge blocker test
route_task_session now calls assess_infra_stop() live instead of
check_mid_merge(). Update TestMCPHealth.test_route_task_session_blocks_during_merge
to mock the infra-stop assessment path and assert infra_stop_assessment
is returned when blocked.

Part of #285 / PR #286 review fix.
2026-07-07 04:35:34 -04:00
sysadminandClaude Opus 4.8 456e46d3fc feat: enforce session hard-stop after terminal review mutation (#332)
Extends the single-terminal-decision machinery (#211) to session-level
hard-stop semantics:

- terminal_review_hard_stop_reasons: after a live terminal verdict, only
  the merge sequence for the same approved PR may continue; REQUEST_CHANGES
  stops the run; operator-approved corrections re-open the review path
  only, never a cross-PR merge.
- gitea_merge_pr: new Gate 2b consults the hard-stop before any API call,
  so a run can never verdict one PR and merge another.
- gitea_mark_final_review_decision: hard-stop check with explicit stop
  guidance naming the consumed terminal mutation; plus duplicate
  REQUEST_CHANGES suppression — an unresolved request-changes at the
  current head SHA refuses a second request-changes (fail closed when
  feedback cannot be verified).
- tests/test_terminal_review_hard_stop.py: 13 tests covering the
  acceptance criteria; existing TestSubmitPrReview setups stub the new
  feedback fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 04:32:15 -04:00
sysadmin 25c9d20870 merge: resolve PR #335 conflicts with master (fd396df)
Keep master router-style SKILL.md (full #333 split) and merge #334
review-merge test coverage into test_llm_workflow_split.py.

Conflicts resolved in:
- skills/llm-project-workflow/SKILL.md
- tests/test_llm_workflow_split.py
2026-07-07 04:31:42 -04:00
sysadmin 873bcd06e5 docs: pin GlitchTip filing orchestrator contract (Closes #153) 2026-07-07 04:27:02 -04:00
sysadminandClaude Opus 4.8 c41a698a08 feat(reports): classify git fetch as a git ref mutation (closes #297)
Reviewer reports treated 'git fetch prgs master' as read-only
diagnostics while claiming 'Git/worktree mutations: None'. Fetch edits
no files but updates remote-tracking refs, so hiding it under
diagnostics understates what the run mutated.

Add git_ref_mutating_commands() classifying fetch, pull, push, merge,
update-ref, remote update, branch -d/-D, and reset --hard as git ref
mutations, and assess_git_ref_mutation_report() which fails a report
when ref-updating commands ran but the report lacks a non-none 'Git ref
mutations' entry, omits 'fetched <remote>/<branch>' for a targeted
fetch, still claims 'Git/worktree mutations: None', or lists the
command under read-only diagnostics.

Tests cover fetch-only review, missing ledger entry, missing fetch
target, contradictory none-claim, read-only misclassification, worktree
creation (not a ref mutation), merge, cleanup branch deletion,
no-mutation blocked handoff, and dict command-log entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 04:26:58 -04:00
sysadmin 5c4153ad57 Merge branch 'master' into feat/issue-285-live-infra-stop-recompute 2026-07-07 03:25:51 -05:00
sysadmin 8929286276 feat: add pagination metadata to gitea_list_prs (#340)
Return wrapped {prs, pagination} from gitea_list_prs with has_more,
inventory_complete, and page traversal via api_fetch_page. Route
gitea_review_pr inventory through gitea_list_prs so trust gates can
prove pagination finality. Add assess_list_prs_pagination_proof verifier.

Closes #340
2026-07-07 04:25:02 -04:00
sysadmin 14f5c865b3 feat: add create-issue final-report verifier (#337)
Extend review_proofs with assess_create_issue_final_report to enforce
workflow-source proof, create-issue handoff schema fields, and rejection
of work-issue/review-merge cross-mode handoff fields.

Closes #337
2026-07-07 04:23:45 -04:00
sysadmin fd396df334 Merge pull request 'Add final-report mutation ledger verifier (#331)' (#338) from feat/issue-331-mutation-ledger-verifier into master 2026-07-07 03:10:30 -05:00
sysadminandClaude Opus 4.8 ae4dd0ff91 Add final-report mutation ledger verifier (#331)
Introduce assess_mutation_ledger_report so reviewer final reports cannot
claim no file edits when the action log records performed mutations.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 04:08:27 -04:00
sysadmin c73e68bd75 Merge pull request 'feat(reports): add no-email identity summary and disclosure validator (closes #305)' (#329) from feat/issue-305-report-email-redaction into master 2026-07-07 03:03:12 -05:00
sysadmin a73d5b351e Merge pull request 'Add REQUEST_CHANGES override proof helper before approval (#326)' (#328) from feat/issue-326-request-changes-override-proof into master 2026-07-07 02:59:33 -05:00
sysadmin 7fdf803a50 Merge pull request 'feat: add fail-closed subagent delegation gates (Closes #266)' (#291) from feat/issue-266-subagent-gate-inheritance into master 2026-07-07 02:56:00 -05:00
sysadmin f7621b155e Merge pull request 'docs: split LLM project workflow into task-specific modes' (#336) from feat/issue-333-work-issue-workflow-extract into master 2026-07-07 02:47:03 -05:00
sysadmin 6200e6bbec docs: split LLM project workflow into task-specific modes (Closes #333)
Consolidate all four canonical workflow files and router SKILL.md:
- review-merge-pr.md (from #334 canonical prompt)
- reconcile-landed-pr.md (reconciliation gates + handoff schema)
- create-issue.md (canonical issue-creation prompt)
- work-issue.md (canonical author/coder prompt)

SKILL.md is now a short router with mode isolation and universal rules.
Adds final-report schemas for all modes and 13 doc-contract tests.

Supersedes partial split work in PR #335; full #333 acceptance in one PR.
2026-07-07 03:44:21 -04:00
sysadmin 8d7203d701 feat: extract canonical work-issue workflow from monolithic skill (Closes #333)
Add workflows/work-issue.md with full author/coder gates (§0–§30),
work-issue final-report schema, task-mode router entry, start-issue template
link, and doc-contract tests.

Part of #333 workflow split (#334 review-merge extraction remains in PR #335).
2026-07-07 03:40:13 -04:00
sysadminandClaude Opus 4.8 c05b2f4c05 docs(skill): load canonical review-merge prompt into workflow file
Replace summary workflow with full §0–§38 canonical PR review/merge rules.
Align review-merge-final-report schema with §37 controller handoff fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 03:37:42 -04:00
sysadminandClaude Opus 4.8 3976f886e4 feat(skill): extract review-merge-pr workflow from monolithic SKILL.md
Closes #334

- Add workflows/review-merge-pr.md with reviewer-only gates and steps
- Add schemas/review-merge-final-report.md with mutation ledger fields
- Turn SKILL.md into task-mode router; replace inlined §F/§G with links
- Point review/merge templates at extracted workflow
- Add doc-contract tests for split structure

Part of #333.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 03:32:59 -04:00
sysadmin e4d78a5013 Merge pull request 'test: make sibling-worktree skip test hermetic (closes #283)' (#288) from fix/issue-283-hermetic-walk-root-test into master 2026-07-07 02:08:38 -05:00
sysadmin e96f5bda7a Merge pull request 'feat: add validation ledger and final-report verifier (Closes #271)' (#287) from feat/issue-271-validation-ledger-verifier into master 2026-07-07 02:03:33 -05:00
sysadmin f5f364739b Merge branch 'master' into feat/issue-271-validation-ledger-verifier 2026-07-07 02:01:25 -05:00
sysadminandClaude Opus 4.8 a3b79736e0 feat(reports): add no-email identity summary and disclosure validator (closes #305)
Workflow reports included the authenticated user's personal email even
when username/profile identity was sufficient (observed: 'Identity:
jcwalker3 / [email protected]' in a reconciliation handoff).

Add format_identity_summary() producing the canonical no-email identity
line ('<username> / <profile>' with optional role/remote, reducing an
email passed in the username slot to its local part), and
assess_email_disclosure() flagging personal email addresses in report
text unless the report itself justifies the disclosure (tool proof,
explicit request, or identity disambiguation).

Tests cover no-email summaries for author and reviewer identities,
role/remote suffixes, email-in-username reduction, clean reports,
single and multiple unjustified disclosures, and justified
disambiguation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 02:57:45 -04:00
sysadminandClaude Opus 4.8 096bb878d5 Merge prgs/master into feat/issue-266-subagent-gate-inheritance
Resolve _GUIDE_RULES conflict by keeping both operator guide keys:
shell_spawn_hard_stop (#258, landed on master via PR #281) and
subagent_delegation (#266, this branch).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 02:55:15 -04:00
sysadminandClaude Opus 4.8 efb0a2d076 Add REQUEST_CHANGES override proof helper before approval (#326)
Introduce assess_request_changes_approval_proof in review_proofs.py so
review workflows can fail closed when approving an unchanged head after a
prior REQUEST_CHANGES unless explicit override proof is provided.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 02:53:25 -04:00
sysadmin a5341684f6 Merge pull request 'feat: align claim/lock gates with branches-only worktrees (Closes #275)' (#280) from feat/issue-275-claim-lock-branches-worktree into master 2026-07-07 01:39:35 -05:00
sysadmin 2a28fee7f2 Merge pull request 'docs: add Shell Spawn Hard-Stop Rule for agent workflows (Closes #258)' (#281) from docs/issue-258-shell-spawn-hard-stop into master 2026-07-07 01:18:50 -05:00
sysadmin 95603b24a8 Merge pull request 'docs: forbid improvised commit fallbacks when MCP commit exists (Closes #260)' (#279) from docs/issue-260-forbid-commit-fallbacks into master 2026-07-07 01:01:16 -05:00
sysadmin ceb22e0d65 Merge branch 'master' into fix/issue-283-hermetic-walk-root-test 2026-07-07 01:01:11 -05:00
sysadmin aae189a801 Merge branch 'master' into feat/issue-271-validation-ledger-verifier 2026-07-07 01:01:01 -05:00
sysadmin 988a5cd975 Merge branch 'master' into feat/issue-285-live-infra-stop-recompute 2026-07-07 01:00:48 -05:00
sysadmin 52b729e86a Merge branch 'master' into docs/issue-259-subagent-tool-budget 2026-07-07 01:00:37 -05:00
sysadmin 3354da1973 Merge branch 'master' into docs/issue-258-shell-spawn-hard-stop 2026-07-07 01:00:06 -05:00
sysadmin bcf64928b1 Merge branch 'master' into feat/issue-275-claim-lock-branches-worktree 2026-07-07 00:59:50 -05:00
sysadmin d0e250e860 Merge branch 'master' into docs/issue-260-forbid-commit-fallbacks 2026-07-07 00:59:30 -05:00
sysadmin a4736a2346 Merge branch 'master' into feat/issue-266-subagent-gate-inheritance 2026-07-07 00:56:30 -05:00
sysadmin 042783076f Merge pull request 'docs: agent temp artifact cleanup guidance (Closes #261)' (#277) from docs/issue-261-agent-artifact-cleanup into master 2026-07-06 23:38:44 -05:00
sysadmin 8fc41ccf29 Merge pull request 'feat: map gitea_commit_files in task capability resolver (Closes #262)' (#276) from feat/issue-262-commit-files-capability into master 2026-07-06 23:35:31 -05:00
sysadminandClaude Opus 4.8 e09df4a348 feat: add fail-closed subagent delegation gates (#266)
Adds subagent_gate.py following the author_proofs/review_proofs pure-policy
pattern:

- assess_subagent_delegation: deterministic write tasks (issue claim,
  branch creation, code edits, commits, PR creation, review, merge,
  cleanup) are blocked for subagents unless explicitly allowed with a
  recorded justification and the full inherited gate context (issue lock,
  branch/worktree path, identity/profile, allowed tool class, command
  deny list, validation ledger requirement, final report schema);
  read-only delegation stays allowed; unknown task types fail closed.
- validate_subagent_report: subagent output is accepted only when it
  carries the same proof fields as a parent workflow final report.
- Operator guide: new subagent_delegation rule in _GUIDE_RULES so MCP
  clients discover the policy.
- tests/test_subagent_gate.py: 13 tests covering blocked write
  delegation, justification requirement, missing/blank inherited
  context, allowed read-only delegation, unknown tasks, and invalid
  subagent final reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:28:32 -04:00
sysadminandClaude Opus 4.8 b5a025102d feat: add validation ledger and final-report verifier (Closes #271)
Introduce validation_ledger with command ledger format, full-suite claim
checks, and structured final-report verification so sessions cannot
overclaim validation or misreport review/merge state.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:22:28 -04:00
sysadminandClaude Opus 4.8 5af1b796ef fix: recompute infra_stop live on every capability resolve (#285)
Add assess_infra_stop() to scan git state and conflict markers on each
reviewer capability check, return the exact conflict path in responses,
and clear stale capability-stop terminal state once the worktree is clean.

Closes #285

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:21:18 -04:00
sysadminandClaude Opus 4.8 2b04108f93 test: make sibling-worktree skip test hermetic (closes #283)
test_skip_scan_walk_root_skips_sibling_worktrees_only derived its
fixture paths from __file__, so the branches/ sibling-worktree skip
assertion only held when the suite ran from a branches/<slug> worktree
whose orchestration checkout has a real branches/ directory —
skip_python_scan_walk_root skips a branches/ walk root only when
<project_root>/branches exists on disk. From the main checkout or any
plain clone the test failed with 'AssertionError: False is not true'.

Build the <main>/branches/<worktree> layout in a TemporaryDirectory and
assert against those paths instead. All four behavioural assertions are
preserved and no production code changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:19:43 -04:00
sysadminandClaude Opus 4.8 82e6d3a3f4 docs: add subagent tool-budget guardrails for MCP tasks (Closes #259)
Document default tool-call and wall-time budgets for deterministic MCP
work, forbid subagent commit delegation when gitea_commit_files is
available, and pin the rules in runbooks, portable skill, operator guide,
and doc-contract tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:17:26 -04:00
sysadminandClaude Opus 4.8 4cc31a42f9 feat: add merged PR cleanup reconciliation report (#269)
Implement gitea_reconcile_merged_cleanups with dry-run reporting for merged
PR remote branches and local branches/ worktrees, plus confirmation-gated
execute mode. Safety gates cover protected branches, open PR references,
active issue locks, dirty worktrees, and delete_branch capability.

Closes #269

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:16:42 -04:00
sysadminandClaude Opus 4.8 cf292e4166 docs: add Shell Spawn Hard-Stop Rule for agent workflows (#258)
Documents fail-closed behavior when the shell executor fails to spawn
(exit_code: -1, empty stdout/stderr): probe once, mark shell unavailable,
hard-stop after two consecutive spawn failures, and emit a recovery report
(restart session, kill hung background terminals, prefer MCP-native paths)
instead of retry-spiralling.

- skills/llm-project-workflow/SKILL.md: portable rule section
- docs/llm-workflow-runbooks.md: Gitea runbook section
- gitea_mcp_server.py: shell_spawn_hard_stop operator guide rule
- tests/test_shell_spawn_hard_stop_docs.py: doc-contract tests (5)
- tests/test_operator_guide.py: require the new guide key

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:52:12 -04:00
sysadmin d73a070b5f Merge remote-tracking branch 'origin/feat/issue-275-claim-lock-branches-worktree' into feat/issue-275-claim-lock-branches-worktree 2026-07-06 14:51:02 -04:00
sysadminandClaude Opus 4.8 5e64c96851 feat: align claim/lock gates with branches-only worktrees (#275)
Allow issue lock from base-equivalent branches/ worktrees instead of
requiring the literal master/main branch name. Runtime preflight and
mark_issue/lock_issue now inspect the declared active task workspace so
dirty control-checkout state does not block clean task worktrees.

Closes #275

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:49:39 -04:00
sysadminandClaude Opus 4.8 67e34baece feat: align claim/lock gates with branches-only worktrees (#275)
Allow issue lock from base-equivalent branches/ worktrees instead of
requiring the literal master/main branch name. Runtime preflight and
mark_issue/lock_issue now inspect the declared active task workspace so
dirty control-checkout state does not block clean task worktrees.

Closes #275

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:47:56 -04:00
sysadminandClaude Opus 4.8 2f4672218c docs: forbid improvised commit fallbacks when MCP commit exists (#260)
Document MCP-native gitea_commit_files as the only approved commit path when
gitea.repo.commit is allowed; ban WebFetch, Playwright, and manual base64
workarounds in runbooks and safety model. Add doc tests.

Closes #260.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:44:41 -04:00
sysadmin b50d265247 Merge branch 'feat/issue-263-native-commit-payloads' into docs/issue-261-agent-artifact-cleanup 2026-07-06 14:44:33 -04:00
sysadmin 2a544b78d1 feat(mcp): support native commit payload preparation without shell (closes #263) 2026-07-06 14:42:59 -04:00
sysadminandClaude Opus 4.8 850b7c34b6 docs: agent temp artifact cleanup guidance and detection (#261)
Add runbook section, root-level .gitignore patterns, and detection helper
for _encode_/_emit_/_inline_ throwaway scripts. Surface non-blocking
warnings from assess_preflight_status and gitea_lock_issue.

Closes #261

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:42:33 -04:00
sysadminandClaude Opus 4.8 f464716e89 docs: document agent temp artifact cleanup and gitignore patterns (#261)
Add runbook guidance for deleting throwaway _encode/_emit/_inline commit
helpers after MCP commit attempts, ignore those patterns in .gitignore,
and cross-link the worktree-cleanup template.

Closes #261.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:42:22 -04:00
sysadmin 67634c399e Merge branch 'feat/issue-262-commit-files-capability' into feat/issue-263-native-commit-payloads 2026-07-06 14:41:07 -04:00
sysadmin 89c9b9a4ec feat(mcp): add commit_files gates and tests for capability resolver 2026-07-06 14:39:26 -04:00
sysadminandClaude Opus 4.8 23c6593b07 feat: map commit_files in task capability resolver (#262)
Add commit_files and gitea_commit_files resolver entries for
gitea.repo.commit, align gitea_commit_files tool gates with other
issue-write mutations, and add regression tests for author allow,
reviewer deny, preflight order, and resolver/tool alignment.

Refs #262

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:38:35 -04:00
sysadmin d6f4f936e3 Merge pull request 'feat(issue-filing): harden final report proofs for handoff and mutations (closes #191)' (#241) from feat/issue-191-issue-filing-reports into master 2026-07-06 13:37:41 -05:00
sysadminandClaude Opus 4.8 24d8891424 fix(issue-filing): enforce mutation-scope denials in generic loop (#191)
The forbidden-mutation loop in assess_issue_filing_mutation_scope never
appended reasons; align aggregation with issue-selection handoff checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:36:18 -04:00
sysadmin c1e47a5692 Merge pull request 'feat: expose role-aware runtime context capabilities (#139)' (#257) from feat/issue-139-role-aware-runtime-context into master 2026-07-06 13:34:37 -05:00
sysadminandClaude Opus 4.8 27922c67f8 feat(issue-filing): harden final report proofs for handoff and mutations (#191)
Add assess_issue_filing_final_report and supporting checks for Controller
Handoff (issue_filing role), exact issue number/title, full 40-char SHAs,
per-mutation capability proof, single-mutation scope statements, and
duplicate-search summaries. Workflow skill documents the issue-filing
handoff fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:32:48 -04:00
sysadminandClaude Opus 4.8 95d01b07fe feat: add session task capabilities to runtime context (#139)
gitea_get_runtime_context now reports per-task allowed_in_current_session
flags, matching configured profiles, and aggregate session_capabilities so
LLMs can determine author/comment/review/merge ability without guessing
launcher profile config.

Refs #139

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:32:07 -04:00
sysadmin 183e9f08b8 Merge pull request 'docs: separate Jenkins trigger onto jenkins-write-mcp boundary (#152)' (#264) from codex/issue-152-jenkins-trigger-boundary into master 2026-07-06 13:26:16 -05:00
sysadmin eb3560d949 Merge pull request 'docs: add Work Selection Rule for LLM operating rules (#272)' (#273) from docs/issue-272-work-selection-lease-rule into master 2026-07-06 13:23:17 -05:00
sysadminandClaude Opus 4.8 216fa5cf46 docs: add Global LLM Worktree Rule to operating rules (#272)
Require mutations only from branches/ worktrees after proving project
root, cwd, branch, stable main-checkout branch, and session worktree
path. Surface in portable skill, runbooks, wiki, operator guide, and
start-issue template.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:09:44 -04:00
sysadminandClaude Opus 4.8 7a28d09b5d fix(mcp): line-anchor conflict scan and worktree scan paths (#272)
Match git conflict markers on whole lines only so decorative equals
borders in mcp_discoverability.py no longer false-positive. Scan
session worktrees under branches/ without skipping the entire tree
when the worktree path contains "branches".

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:09:44 -04:00
sysadminandClaude Opus 4.8 2111c84e7d docs: add Work Selection Rule for LLM operating rules
Require lease verification (open PRs, branches, worktrees, leases,
merged completion) before any issue or PR work. Surface the rule in
the portable skill, Gitea runbooks, operator guide, and start-issue
template; add doc-contract tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 13:45:25 -04:00
sysadmin 0716157fa5 Merge pull request 'feat(mcp): implement operation-scoped role selection and automatic dispatch switching (#228)' (#239) from feat/issue-228-operation-scoped-role-selection into master 2026-07-06 12:39:07 -05:00
sysadmin 5798871cc2 Merge pull request 'feat(mcp): add integration and discoverability coverage for Jenkins/GlitchTip' (#255) from feat/issue-155-jenkins-glitchtip-discoverability into master 2026-07-06 12:37:41 -05:00
sysadmin 84ed137f66 Merge pull request 'fix: scope pre-flight workspace checks to session-owned deltas (closes #252)' (#254) from feat/issue-252-preflight-foreign-edits into master 2026-07-06 12:35:45 -05:00
sysadminandClaude Opus 4.8 b4e04f4dfb docs: separate Jenkins trigger onto jenkins-write-mcp boundary (#152)
Document that build triggers live on the gated jenkins-write-mcp server,
not the read-only jenkins-mcp surface. Add registration and safety-model
cross-links plus doc tests that fail if trigger tools reappear on the read
boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 13:26:58 -04:00
sysadminandClaude Opus 4.8 4faf839dab test: align preflight tests with session-scoped porcelain baselines
Update TestPreflightVerification for #252 delta-based preflight checks
on the rebased #228 branch.

Refs #228

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 12:39:01 -04:00
sysadmin 7dde2f5405 fix(mcp): implement operation-scoped role selection and auto profile switching 2026-07-06 12:38:20 -04:00
sysadmin 75ed0632b4 feat(mcp): implement operation-scoped role selection and automatic dispatch switching (#228) 2026-07-06 12:37:30 -04:00
sysadmin 1fd929040c feat(mcp): add integration and discoverability coverage for Jenkins/GlitchTip 2026-07-06 12:37:04 -04:00
sysadminandClaude Opus 4.8 f2c8a8d5c1 fix: scope pre-flight workspace checks to session-owned deltas (#252)
Pre-flight order tracking now compares porcelain baselines captured at
process start, whoami, and capability resolution instead of treating any
shared-worktree dirt as a sticky violation. Fresh gitea_whoami re-evaluates
and clears violations; runtime context reports preflight_block_reasons when
mutations would fail.

Refs #252

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 12:34:45 -04:00
sysadmin 5965904c60 Merge pull request 'feat(reviewer): add queue-target lock before PR inventory trust gate (closes #200)' (#240) from feat/issue-200-queue-target-lock into master 2026-07-06 11:29:55 -05:00
sysadminandClaude Opus 4.8 70c868962a feat(reviewer): add queue-target lock before PR inventory trust gate (#200)
Operators can supply an active PR backlog for one repo while agents
inventory another and falsely report trusted_empty. reconcile_queue_target
locks the target repo from operator context/backlog before list_prs runs;
pr_inventory_trust_gate blocks target_repo_mismatch and unresolved locks.
assess_reviewer_queue_inventory wires the lock into the canonical path.

Tests cover Gitea-Tools backlog vs mcp-control-plane inventory mismatch,
ambiguous dual-repo context, conflicting directive vs backlog, and final-
report field requirements.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 12:29:16 -04:00
sysadmin 08ed5a82d2 Merge pull request 'feat(author): add continuation-mode selection wall for open-PR issues (closes #188)' (#246) from feat/issue-188-continuation-selection-wall into master 2026-07-06 11:26:10 -05:00
sysadmin a887da1f8f Merge pull request 'feat(mcp): bind issue lock to author scratch worktree (closes #249)' (#250) from feat/issue-249-issue-lock-scratch-worktree into master 2026-07-06 11:24:37 -05:00
sysadmin f94cb80fc9 Merge pull request 'fix(mcp): require visible APPROVED reviews before merge (closes #244)' (#251) from feat/issue-244-review-submit-visible into master 2026-07-06 11:22:14 -05:00
sysadmin 6e27911733 Merge pull request 'fix(mcp): clear sticky reviewer denial on allowed author task route (closes #238)' (#242) from feat/issue-238-sticky-denial-clear into master 2026-07-06 11:22:00 -05:00
sysadminandClaude Opus 4.8 9d8ab0a7b5 test: fix PR queue inventory mock for visible APPROVED verification (#244)
Update test_reviewer_profile_can_proceed_from_inventory_to_review_gates
to return APPROVED state on POST and include GET /reviews verification
responses required by #244 review submission gates.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 12:21:26 -04:00
sysadmin c502ae30d6 Merge pull request 'test: make test_health portable for scratch clones (closes #245)' (#248) from feat/issue-245-test-health-portable into master 2026-07-06 11:20:21 -05:00
sysadmin ff435ea13c Merge pull request 'fix(reviewer): close forbidden-git detection gaps via allowlist (closes #243)' (#247) from feat/issue-243-forbidden-git-gaps into master 2026-07-06 11:18:32 -05:00
sysadmin a1ba69eebb fix(mcp): require visible APPROVED reviews before merge (closes #244)
Gitea expects review event APPROVED, not APPROVE; the wrong event left
submissions as PENDING drafts. Verify terminal verdicts after submit,
submit pending drafts when needed, and block merge when approval_visible
is false.
2026-07-06 11:51:29 -04:00
sysadminandClaude Opus 4.8 df1104d3e7 feat(mcp): bind issue lock to author scratch worktree (#249)
gitea_lock_issue and gitea_create_pr accept worktree_path so lock
preconditions validate the caller's scratch clone instead of the shared
MCP server CWD. Lock records store the validated path; PR creation fails
closed when the declared path does not match.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 11:46:44 -04:00
sysadmin e441b81d3b Merge branch 'master' into feat/issue-188-continuation-selection-wall 2026-07-06 11:44:59 -04:00
sysadminandClaude Opus 4.8 d422bc0978 test: make test_health portable for scratch clones (#245)
Use sys.executable with optional GITEA_TOOLS_TEST_PYTHON override instead of
hard-coded venv/bin/python. Add subprocess timeouts/teardown and skip when no
interpreter is available. Note scratch-clone norm in review-pr template.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 11:42:34 -04:00
sysadminandClaude Opus 4.8 ec8f6abf5b fix(reviewer): close forbidden-git detection gaps via allowlist (#243)
Replace blocklist-based git mutation detection with fail-closed allowlist
semantics: any git command not matching the readonly set is forbidden.
Catches checkout HEAD --, checkout ., git switch variants, and uncommon
stash subcommands that bypassed the old regex.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 11:42:14 -04:00
sysadminandClaude Opus 4.8 dc41b685d0 feat(author): add continuation-mode selection wall for open-PR issues (#188)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 11:40:35 -04:00
sysadminandClaude Opus 4.8 63a7ba8287 feat(author): add continuation-mode selection wall for open-PR issues (#188)
Issues already represented by open PRs must not be selected for fresh work
unless the operator explicitly requests continuation. Adds classification,
continuation report proofs (old/new head SHA, PR author, branch), contradictory
no-PR claim detection, and edited-PR inventory coverage checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 11:40:20 -04:00
sysadmin bab803ff3d Merge pull request 'test: require issue-lock proof in author handoffs (closes #208)' (#235) from feat/issue-208-issue-lock-proof into master 2026-07-06 10:39:12 -05:00
sysadminandClaude Opus 4.8 4bc02a8c7d fix(reviewer): restore #198 empty-queue helpers for PR #235 (#208)
Re-introduce assess_empty_queue_report and parse_trust_gate_status_from_report
dropped when #208 was reverted from master. Wire build_final_report grading,
inventory handoff trust-gate fields, and tests so capability_stop_terminal
imports resolve while issue-lock proof enforcement remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 11:37:07 -04:00
sysadminandClaude Opus 4.8 d4e89f7863 fix(mcp): clear sticky reviewer denial on allowed author task route (#238)
Add sync_from_capability_result() so reviewer terminal mode is
operation-scoped: a later allowed author capability resolution clears
stale denial state and unblocks read-only tools like gitea_list_prs.

Error messages now name the denied task and explain how to clear stale
state. Inline trust-gate parsing fixes broken imports in
assess_capability_stop_report on master.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 11:27:29 -04:00
jcwalker3 ec879df4c2 test: require issue-lock proof in author handoffs (#208) 2026-07-06 10:19:46 -05:00
jcwalker3 056a232ef8 revert: remove #208 from master (land via PR #235) 2026-07-06 10:19:26 -05:00
sysadmin 8fa94a07a8 Merge pull request 'feat(reviewer): require trust-gate proof in empty-queue reports (closes #198)' (#237) from feat/issue-198-empty-queue-trust-gate-report into master 2026-07-06 10:18:55 -05:00
sysadminandClaude Opus 4.8 ca3de3da53 feat(reviewer): require trust-gate proof in empty-queue reports (#198)
Add assess_empty_queue_report to block empty-queue claims without
pr_inventory_trust_gate.status == trusted_empty, reject weak merge-commit
corroboration, extend inventory handoff fields, and wire the gate into
build_final_report and capability-stop terminal validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 11:16:35 -04:00
sysadmin be6feabf70 Merge pull request 'feat(reviewer): wire PR inventory trust gate into live queue path (closes #196)' (#236) from feat/issue-196-pr-inventory-trust-gate-live-path into master 2026-07-06 10:14:22 -05:00
sysadminandClaude Opus 4.8 1071619532 feat(reviewer): fail closed on dirty worktrees and forbidden git cleanup (#233)
Add reviewer_worktree proofs that block stash/reset/checkout -- cleanup of
unrelated local files, require scratch worktree reporting when the main tree
is dirty outside PR scope, and integrate the gate into final report grading
and Controller Handoff review fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 11:13:38 -04:00
sysadminandClaude Opus 4.8 1033a22407 feat(reviewer): wire PR inventory trust gate into live queue path (#196)
Run pr_inventory_trust_gate on empty gitea_review_pr inventory results,
add assess_reviewer_queue_inventory for multi-repo completeness checks,
and require trusted_empty before empty-queue claims in reports/templates.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 02:08:55 -04:00
sysadmin 7966e70db6 Merge pull request 'feat(reviewer): fail closed on dirty worktrees and forbidden git cleanup (closes #233)' (#234) from feat/issue-233-reviewer-worktree-gate into master 2026-07-06 01:06:24 -05:00
sysadminandClaude Opus 4.8 4f466550ca feat(reviewer): fail closed on dirty worktrees and forbidden git cleanup (#233)
Add reviewer_worktree proofs that block stash/reset/checkout -- cleanup of
unrelated local files, require scratch worktree reporting when the main tree
is dirty outside PR scope, and integrate the gate into final report grading
and Controller Handoff review fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 02:04:34 -04:00
sysadminandClaude Opus 4.8 6ac6b9528c test: require issue-lock proof in author handoffs (#208)
Add Issue lock proof to author HANDOFF_ROLE_FIELDS and update controller
handoff tests. Strengthen create_pr tests to read and validate the issue
lock file (branch match, Closes #N, reveal opt-in unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 02:02:26 -04:00
sysadmin 4dd32bb9f7 Merge pull request 'docs(wiki): refresh Gitea Wiki publication proof heads (closes #224)' (#231) from feat/issue-224-wiki-proof-refresh into master 2026-07-06 00:57:29 -05:00
sysadmin d5d3331498 docs(wiki): refresh Gitea Wiki publication proof heads (closes #224) 2026-07-06 01:49:59 -04:00
81 changed files with 17857 additions and 921 deletions
+4
View File
@@ -10,3 +10,7 @@ gitea-mcp*.json
.vscode/
graphify-out/
branches/
# Throwaway agent commit-encoding helpers (#261) — never commit.
/_encode_*.py
/_emit_*.py
/_inline_*.py
+26
View File
@@ -0,0 +1,26 @@
"""Detect throwaway agent helper scripts left in the repo root (#261)."""
from __future__ import annotations
import fnmatch
AGENT_TEMP_BASENAME_PATTERNS = (
"_encode_*.py",
"_emit_*.py",
"_inline_*.py",
)
def find_agent_temp_artifacts_from_porcelain(porcelain: str) -> list[str]:
"""Return untracked repo-root helper paths matching agent temp patterns."""
found: list[str] = []
for line in (porcelain or "").splitlines():
if not line.startswith("??"):
continue
path = line[3:].strip()
if not path or "/" in path or "\\" in path:
continue
basename = path.split("/")[-1]
if any(fnmatch.fnmatch(basename, pat) for pat in AGENT_TEMP_BASENAME_PATTERNS):
found.append(path)
return sorted(found)
+34 -4
View File
@@ -53,6 +53,28 @@ def enter_from_capability_result(capability: dict) -> dict | None:
return dict(record)
def _is_reviewer_denial(capability: dict) -> bool:
task = (capability or {}).get("requested_task", "")
required_role = (capability or {}).get("required_role_kind")
return (
required_role == "reviewer"
or task in REVIEWER_CAPABILITY_TASKS
)
def sync_from_capability_result(capability: dict) -> dict | None:
"""Enter or clear terminal mode from a capability resolution (#238).
Reviewer denials activate terminal mode for the denied operation only.
A later allowed task route clears stale denial state so author read-only
tools (e.g. ``list_prs``) are not permanently blocked.
"""
if (capability or {}).get("stop_required") and _is_reviewer_denial(capability):
return enter_from_capability_result(capability)
clear()
return None
def enter_from_route_result(route: dict) -> dict | None:
"""Enter terminal mode from a role router wrong_role_stop (#206 compat)."""
if (route or {}).get("route_result") != "wrong_role_stop":
@@ -90,11 +112,13 @@ def check_reviewer_queue_tool(tool_name: str) -> tuple[bool, list[str]]:
return True, []
name = (tool_name or "").strip().lower().removeprefix("gitea_")
if name in BLOCKED_QUEUE_TOOLS:
denied_task = (_session_terminal or {}).get("requested_task") or "unknown"
return False, [
TERMINAL_REPORT_HEADING,
f"Reviewer queue tool '{tool_name}' is blocked after "
"capability denial (fail closed).",
"Relaunch a reviewer MCP namespace to perform reviewer work.",
f"Reviewer queue tool '{tool_name}' is blocked by the current "
f"capability denial for task '{denied_task}' (fail closed).",
"Resolve or route an allowed author task to clear stale denial "
"state, or relaunch a reviewer MCP namespace for reviewer work.",
]
return True, []
@@ -148,8 +172,14 @@ def assess_capability_stop_report(
r"inventory empty",
re.I,
)
parsed_status = None
for line in text.splitlines():
if "pr_inventory_trust_gate.status:" in line.lower():
parsed_status = line.split(":", 1)[1].strip()
break
effective_status = trust_gate_status or parsed_status
if empty_queue_patterns.search(text):
if trust_gate_status != "trusted_empty":
if effective_status != "trusted_empty":
violations.append(
"empty-queue claim after capability stop without "
"pr_inventory_trust_gate.status == trusted_empty"
@@ -1,21 +1,34 @@
# GlitchTip-to-Gitea Issue Filing Workflow Design
# GlitchTip-to-Gitea Issue Filing Workflow Contract
- **Status:** Design (no implementation in this repo)
- **Issue:** #74 (parent umbrella: #75)
- **Status:** Contract for the library-only implementation in
`Scaled-Tech-Consulting/mcp-control-plane` (#57)
- **Issue:** #153 (supersedes #74/#78 as the Gitea-Tools tracker)
- **Related:** #78 (deduplication design, child of #74)
- **Date:** 2026-07-02
- **Date:** 2026-07-07
## 1. Boundary and Orchestration
* **GlitchTip-to-Gitea filing is NOT a GlitchTip MCP capability.** The `glitchtip-mcp` boundary remains strictly read-only per ADR-0001.
* The filing capability lives in an **orchestrator / runbook / release workflow**. It **composes** separate GlitchTip **read** tools (from the `glitchtip-mcp` server) and Gitea **issue** tools (from the `gitea-mcp` server).
* The filing capability lives in a **library-only orchestrator** in
`mcp-control-plane` and is not exposed through `glitchtip-mcp`.
* The orchestrator composes the real GlitchTip read path with Gitea
issue-write tools. It must not use mocked GlitchTip issue data in production
filing paths.
* The orchestrator **must not centralize credentials** into a single server. The GlitchTip MCP holds only GlitchTip tokens, and the Gitea MCP holds only Gitea tokens.
* If a future MCP surface exposes filing, it must be a separate write-boundary
server/profile with explicit Gitea issue-write permission and the same audit
gates. It must not be added to the read-only `glitchtip-mcp` surface.
## 2. Invocation and Safety
* **Explicit invocation only:** There is no automatic, unsupervised filing in phase 1. A human or an explicitly-triggered automation must initiate the workflow.
* **Dry-run / Preview required:** The orchestrator must present a preview of the drafted Gitea issue (title, body, labels) and obtain explicit confirmation before calling the Gitea mutation tool to file the issue.
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation relies on `gitea-mcp`, and therefore inherently subjects the mutation to Gitea profile checks and audit logging as standard.
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation
relies on `gitea-mcp`, and therefore must pass Gitea profile checks and
fail-closed mutation audit before create/link/comment actions.
* **Deduplication before create:** The orchestrator must run the
GlitchTip-to-Gitea dedup/linking logic before any Gitea create action. Create,
link, and skip decisions must be represented in tests.
## 3. Gitea Issue Format
@@ -51,6 +64,23 @@ To prevent PII or secret leakage into Gitea, the orchestrator and the underlying
The principle is: **"Link, don't dump"**. The generated issue acts as an alert/pointer, while the raw context remains protected inside GlitchTip.
## 5. Deduplication and Linking (Deferred)
## 5. Deduplication and Linking
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip issue IDs, and race condition handling) is specifically handled by **Issue #78** and will augment this design.
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip
issue IDs, and race condition handling) is integrated into the library-only
filing orchestrator in `mcp-control-plane`. The orchestrator must reuse that
dedup/linking path instead of duplicating or bypassing it.
## 6. Gitea-Tools Acceptance Contract for #153
Gitea-Tools does not host the filing implementation. This repository owns the
operator-facing contract:
* `glitchtip-mcp` remains read-only and exposes only GlitchTip inspection tools.
* Filing is implemented in `mcp-control-plane`, not in this Gitea MCP runtime.
* Filing uses the real GlitchTip read path, not mocked issue data.
* Dedup runs before any Gitea create action.
* Create/link/skip decisions and audit failure are covered by orchestrator tests.
* Mutation audit fails closed before any Gitea create, link, or comment mutation.
* Any future MCP exposure requires a separate write-boundary server/profile and
must not add Gitea write credentials to `glitchtip-mcp`.
@@ -5,7 +5,7 @@
- **Related:** #77 (repo/branch/PR → job mapping, designed separately)
- **Date:** 2026-07-02
Note on naming: This design used historical `jenkins-readonly` skill name in Gitea-Tools. Actual package/server is `jenkins-mcp` (see mcp-control-plane registration in #55). The server boundary now contains gated trigger (see #56), but read tools remain as designed.
Note on naming: This design used historical `jenkins-readonly` skill name in Gitea-Tools. Actual package/server is `jenkins-mcp` (see mcp-control-plane registration in #55). The read server boundary remains read-only; gated triggers live on the separate `jenkins-write-mcp` / `jenkins_mcp.write_server` boundary (see #56 / #152).
Client registration and reload instructions live in
[`../mcp-client-registration.md`](../mcp-client-registration.md).
@@ -18,7 +18,9 @@ detail (build URL, number, timing, result) to report or investigate.
Phase 1 is **primarily read-only**, per ADR-0001
([`adr-0001-mcp-control-plane-boundaries.md`](adr-0001-mcp-control-plane-boundaries.md)):
- Build triggers are gated behind dedicated profile + exact confirmation (landed in #4, boundary correction in #56).
- Build triggers are outside this read-only surface and require the separate
`jenkins-write-mcp` boundary, a dedicated profile, exact confirmation, and
fail-closed mutation audit (landed in #4, boundary correction in #56 / #152).
- **Excluded: deploy triggers.**
- **Excluded: parameterized job launches.**
- Excluded: job creation/deletion/config changes, queue manipulation, node
@@ -109,7 +111,7 @@ by #76):
`forbidden_operations: ["jenkins.build.trigger", "jenkins.deploy", "jenkins.job.configure"]`
as belt-and-braces even though no mutating tool exists.
- Missing URL/user/token/profile ⇒ **fail closed** with a clear message.
- Since every tool is read-only, no confirmation gates are needed — but
- Since every tool on `jenkins-mcp` is read-only, no confirmation gates are needed — but
identity (`jenkins_whoami`) must still work so workflows can prove which
Jenkins account they act as.
+170
View File
@@ -229,6 +229,122 @@ Legacy environment-only setups keep working unchanged until migrated.
Each runbook names the **profile role** it runs under, the steps, and a safe
prompt. Confirm the active profile first (`gitea_get_profile` / `gitea_whoami`).
## Work Selection Rule for LLMs
Before starting any issue or PR work, acquire or verify a work lease. Do not
begin coding, reviewing, fixing, branching, committing, pushing, commenting,
or creating a PR until you prove the target is not already being worked.
Required checks:
1. List open PRs.
2. Search for PRs linked to the target issue.
3. Search local and remote branches for the issue number.
4. Search registered worktrees for the issue branch.
5. Check dirty worktrees.
6. Check active leases or recent handoffs.
7. Check whether the issue was already completed by a merged PR.
If another active LLM/session owns the lease, stop. Allowed responses:
continue as the lease owner; review the existing PR if reviewer capability
allows; produce a handoff; request takeover after lease expiry; stop with
"work already claimed."
Never create a parallel branch or PR for the same issue unless the old branch
is proven abandoned and the takeover is recorded.
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
mutations), `status:in-progress`, and claim comments. Full portable wording:
[`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
## Global LLM Worktree Rule
The main project checkout is a stable control checkout. It must stay on the
configured stable branch: `master`, `main`, or `dev`.
All LLM task work must happen inside the project's `branches/` directory.
Before any mutation, prove:
1. current project root
2. current working directory
3. current branch
4. stable branch for the main checkout
5. session-owned worktree path under `branches/`
If `cwd` is not inside `branches/`, stop. Do not edit, create, delete, format,
test-write, commit, merge, rebase, checkout task branches, resolve conflicts,
or run cleanup.
There are no exceptions for small fixes, docs, tests, cleanup, PR review fixes,
conflict resolution, or emergencies.
The main checkout may only be used for read-only inspection, fetching,
stable-branch update after merged PRs, creating `branches/` worktrees, or
explicit control-checkout repair.
Portable wording: [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
## Shell Spawn Hard-Stop Rule
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
That is an executor spawn failure, not a command failure — the command never
ran, and retrying the identical call cannot succeed.
Required behavior (fail closed, issue #258):
1. **Probe once.** On the first spawn failure, run one trivial probe
(`echo ok` or `pwd`). If the probe also returns `exit_code: -1`, mark
shell unavailable for the session.
2. **Hard-stop at two.** After two consecutive spawn failures, stop all
further shell tool use for the session; never retry the same failing
spawn. A hundred retries produce a hundred identical failures (session
`019f382e`: 100+ tool calls stalled on a trivial encode-and-commit task).
3. **Emit a recovery report.** The report must direct the operator to:
- restart the session,
- kill hung background terminals (a hung test runner holding the
executor is a known contributor),
- prefer MCP-native paths for remaining mutations (for example
`gitea_commit_files` under `gitea.repo.commit`) instead of shell.
4. **No improvised fallbacks.** Shell unavailability never authorizes
WebFetch/browser/manual-encoding workarounds (see #260). No shell means
stop-and-report.
Doc-contract tests: `tests/test_shell_spawn_hard_stop_docs.py`.
## Subagent Tool-Budget Guardrails
General-purpose subagents tasked with **deterministic MCP work** (for example a
single `gitea_commit_files` call) have expanded to 100122 tool calls,
WebFetch/Playwright fallbacks, and throwaway helper-script generation instead of
calling the native MCP tool once (observed during #152 closure, issue #259).
**Default budgets** (fail closed when exceeded):
| Task class | Max tool calls | Max wall time |
|------------|----------------|---------------|
| Single-step MCP mutation (`commit_files`, `create_pr`, `lock_issue`) | 15 | 5 minutes |
| Review / merge queue inspection | 40 | 15 minutes |
| Exploration / codebase search (non-mutating) | 60 | 20 minutes |
**Required behavior:**
1. **Main session first.** When the active author profile allows
`gitea.repo.commit` and `gitea_commit_files` is visible, the main session
must call it directly — do not delegate commit authority to a subagent
(see #260).
2. **Native MCP before fallback.** After a shell spawn failure (#258), attempt
the native MCP tool once before any alternate path. Shell unavailability
never authorizes WebFetch, Playwright, or manual base64 encoding.
3. **No retry spirals.** Never resume a failed subagent into a larger retry
loop or spawn a second subagent for the same deterministic step. Stop and
emit a recovery report instead.
4. **Forbidden detours** when `gitea_commit_files` is available: WebFetch,
Playwright/browser automation, manual LLM-generated base64, and ad-hoc
`_encode_*` / `_emit_*` helper scripts left in the repo.
Doc-contract tests: `tests/test_subagent_tool_budget_docs.py`.
## Branch worktree isolation
All LLM implementation and review work happens in an isolated branch worktree
@@ -260,6 +376,30 @@ may edit another issue's branch folder unless explicitly assigned to that issue.
No LLM may clean another issue's branch folder unless the PR is merged or closed
and cleanup is explicitly part of the task.
## Agent temp artifact cleanup (#261)
Failed or aborted MCP commit attempts sometimes leave throwaway helper scripts in
the **repository root**. These are not part of any issue scope and pollute
`git status`, which can break `gitea_lock_issue` and preflight checks.
**Patterns (repo root only, untracked):**
- `_encode_*.py` — base64 payload encoders
- `_emit_*.py` — commit payload emitters
- `_inline_*.py` — inline encoding helpers
**Required cleanup (after MCP commit completes or aborts):**
1. Delete any matching files at the repo root (`rm ./_encode_*.py` etc.).
2. Confirm `git status` is clean on the orchestration checkout before
`gitea_lock_issue`.
3. Prefer native `gitea_commit_files` / gated commit paths — do not leave shell
encoding fallbacks behind.
Root-level matches are listed in `.gitignore` so they never get committed.
`gitea_get_runtime_context` and `gitea_lock_issue` surface **warnings** (not
hard blocks) when these artifacts are still present.
Implementation work and review work must use separate branch folders. For
example, an implementation branch might live under
`branches/fix-issue-123-example`, while a review branch for the resulting PR
@@ -314,6 +454,36 @@ git branch -d fix/issue-123-example
All three helpers accept `--dry-run` to print the exact commands/paths without
touching anything.
### MCP-native commit path (#260)
When the active author profile allows **`gitea.repo.commit`** and
**`gitea_commit_files`** is visible in the client, that is the **only** approved
path for committing files to the tracked repository. Do not improvise alternate
encoding or transport when MCP commit is available.
**Required before commit:**
1. Call `gitea_resolve_task_capability` for `commit_files` or
`gitea_commit_files` and confirm `allowed_in_current_session` is true.
2. Use `gitea_commit_files` with file payloads prepared in the author worktree.
3. Stage only issue-scoped paths; never commit throwaway `_encode_*` /
`_emit_*` / `_inline_*` helpers.
**Explicitly forbidden workarounds** when MCP commit is reachable:
- `WebFetch` / HTTP calls to external decode sites (for example httpbin base64
endpoints)
- Playwright or other browser automation to bypass MCP
- Manual LLM-generated base64 pasted into ad-hoc scripts
- Delegating commit authority to a subagent while the main session has
`gitea.repo.commit` on an author profile
**If shell encoding is unavailable** (spawn failure, hung terminal) **and** MCP
commit cannot run: **stop** with a recovery report. Mention restarting the
session, clearing hung background terminals, switching to MCP-native commit, and
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
loop and do **not** substitute WebFetch/Playwright/manual base64.
### Create an issue / child issues
- **Profile:** issue-manager or author (any profile allowed to create issues).
+27 -6
View File
@@ -9,9 +9,14 @@ Use these exact MCP server names in clients:
| Server name | Boundary | Default capability |
|---|---|---|
| `jenkins-mcp` | Jenkins CI inspection | Read-only build/job inspection |
| `jenkins-mcp` | Jenkins CI inspection (read) | Read-only build/job inspection |
| `jenkins-write-mcp` | Jenkins build trigger (write) | Gated `jenkins_trigger_build` only |
| `glitchtip-mcp` | GlitchTip observability inspection | Read-only issue/event inspection |
The write boundary (`jenkins-write-mcp`) is **not** registered by default (#152).
It exposes a single mutating tool and requires operator approval of a dedicated
trigger profile before any client config references it.
Historical names such as `jenkins-readonly` and `glitchtip-readonly` are
descriptive profile labels only. They are not the canonical MCP server names
unless an operator intentionally creates aliases and documents them.
@@ -54,7 +59,7 @@ entry, reconnect or reload the MCP client before claiming the tools are usable.
Before using either server in a task, prove the expected tools are visible in
the client. It is not enough for the config entry to exist.
Expected Jenkins tools:
Expected Jenkins read tools (`jenkins-mcp` only):
- `jenkins_whoami`
- `jenkins_list_jobs`
@@ -62,6 +67,10 @@ Expected Jenkins tools:
- `jenkins_build_status`
- `jenkins_get_build`
`jenkins_trigger_build` must **not** appear on `jenkins-mcp`. When an operator
explicitly enables the write boundary, the only expected tool on
`jenkins-write-mcp` is `jenkins_trigger_build`.
Expected GlitchTip tools:
- `glitchtip_whoami`
@@ -78,10 +87,22 @@ back to shell commands, raw service APIs, or unrelated MCP servers.
## Boundary Rules
- `jenkins-mcp` read profiles must not expose build trigger tools.
- Jenkins build triggers require a separately named write profile, exact
confirmation, and fail-closed mutation audit.
- Build triggers live on the separate `jenkins-write-mcp` server
(`jenkins_mcp.write_server`), not on `jenkins-mcp`.
- Jenkins build triggers require a dedicated trigger profile with
`jenkins.build.trigger` allowed, exact confirmation
(`TRIGGER BUILD <job-path>`), and fail-closed mutation audit.
- Do not register `jenkins-write-mcp` until an operator approves a trigger
profile; no shipped profile carries trigger capability by default.
- `glitchtip-mcp` remains read-only. It must not file or mutate Gitea issues.
- GlitchTip-to-Gitea filing is a separate orchestrator that composes GlitchTip
read tools with Gitea issue-write tools.
- GlitchTip-to-Gitea filing is a separate library-only orchestrator in
`mcp-control-plane` that composes the real GlitchTip read path with Gitea
issue-write tools.
- The filing orchestrator must run GlitchTip/Gitea dedup before any Gitea
create action, and its create/link/skip decisions must be covered by tests.
- The filing orchestrator must fail closed on mutation-audit failure before
any Gitea create, link, or comment mutation.
- If filing is ever MCP-exposed, it must use a separate write-boundary
server/profile. It must not be exposed by `glitchtip-mcp`.
- Gitea credentials never enter Jenkins or GlitchTip runtimes.
- Jenkins and GlitchTip credentials never enter the Gitea MCP runtime.
+25 -2
View File
@@ -21,5 +21,28 @@ Note on naming: Historical design docs used `jenkins-readonly` / `glitchtip-read
## 5. Mutation Gating
Any mutating action (e.g., Gitea issue creation from GlitchTip, or Jenkins builds) must be explicitly allowed by the execution profile.
- **Jenkins build triggers** exist in jenkins-mcp (landed #4) but require dedicated profile/identity and exact confirmation; not on standard reader profiles. See #56 for boundary correction.
- **GlitchTip to Gitea issue filing** is documented as a gated, orchestrated workflow (not in glitchtip-mcp), currently partial (mocked, dedup not wired, audit missing). See #57.
- **Jenkins build triggers** are gated on a separate write boundary
(`jenkins-write-mcp` / `jenkins_mcp.write_server`), not on the read-only
`jenkins-mcp` surface. Triggers require a dedicated profile with
`jenkins.build.trigger`, exact confirmation, and fail-closed mutation audit.
No default profile carries trigger capability (#152 / mcp-control-plane #56).
- **GlitchTip to Gitea issue filing** is a library-only orchestrator in
mcp-control-plane (not on `glitchtip-mcp`). See #153 / mcp-control-plane #57.
## 6. Agent Commit Path (no improvised fallbacks)
When an author execution profile allows **`gitea.repo.commit`** and the
**`gitea_commit_files`** tool is visible, agents must use that MCP path for
repository commits. Fail closed instead of improvising alternate transports.
Forbidden when MCP commit is available:
- WebFetch or other HTTP calls to external base64/decode services
- Playwright or browser automation used to work around MCP commit
- Manual LLM-generated base64 embedded in throwaway scripts as the primary
commit transport
If shell helpers are unavailable and MCP commit cannot run, stop with a recovery
report (restart session, clear hung terminals, use MCP-native commit). See
[`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) § MCP-native commit path
(#260) and agent temp artifact cleanup (#261).
+1
View File
@@ -13,6 +13,7 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server
3. **One unit of work per session** — Implement one claimed issue *or* review/merge one PR; do not mix author and reviewer mutations in one session.
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
## Supported Gitea instances
+3 -2
View File
@@ -31,7 +31,8 @@ Wiki-related issues cannot be closed until the live Wiki is verified — see
| Repository | `docs/wiki/` source | Gitea Wiki published | Proof |
|---|---|---|---|
| `Scaled-Tech-Consulting/Gitea-Tools` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/wiki/Home); 10 pages; wiki git log head `11549ee` |
| `Scaled-Tech-Consulting/mcp-control-plane` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane/wiki/Home) |
| `Scaled-Tech-Consulting/Gitea-Tools` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/wiki/Home); 10 pages; wiki git log head `d1f0693` |
| `Scaled-Tech-Consulting/mcp-control-plane` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane/wiki/Home); 10 pages (History, Home, Identity-and-Profiles, MCP-Tools, Open-Decisions, Operator-Guide, Repositories, Runbooks, Safety-and-Gates, Workflow); wiki git log head `ef3dec2` |
Update this table whenever a wiki is published, re-synced, or found stale.
+6
View File
@@ -14,6 +14,12 @@
## Step 2: Implement issues (author profile)
0. Work Selection Rule — verify a work lease before any mutations (open PRs,
issue-linked PRs, branches, worktrees, dirty worktrees, active leases/
handoffs, merged-PR completion). Stop if another session owns the lease.
0b. Global LLM Worktree Rule — main checkout on `master`/`main`/`dev` only;
mutate only from a `branches/` worktree after proving root, cwd, branch,
stable main-checkout branch, and session worktree path (no exceptions).
1. `gitea_resolve_task_capability` for the author task.
2. `gitea_lock_issue` before implementation mutations.
3. Claim with `gitea_mark_issue` / `status:in-progress` label.
+878
View File
@@ -0,0 +1,878 @@
"""Composable final-report validator for reviewer and reconciliation handoffs (#327).
Single entry point ``assess_final_report_validator`` runs task-specific rule
modules and returns structured findings suitable for controller handoff
``Blocker`` / ``Safe next action`` fields.
"""
from __future__ import annotations
import inspect
import re
from typing import Any, Callable
from review_proofs import (
HANDOFF_HEADING,
assess_controller_handoff,
assess_email_disclosure,
assess_issue_filing_final_report,
assess_mutation_ledger_report,
assess_review_mutation_final_report,
assess_validation_report,
)
FINAL_REPORT_TASK_KINDS = frozenset({
"review_pr",
"reconcile_already_landed",
"author_issue",
"work_issue",
"issue_filing",
"issue_selection",
"inventory",
})
_TASK_KIND_ALIASES = {
"review": "review_pr",
"review-merge-pr": "review_pr",
"reconcile-landed-pr": "reconcile_already_landed",
"reconcile_already_landed": "reconcile_already_landed",
"work-issue": "work_issue",
"create_issue": "issue_filing",
}
_HANDOFF_ROLE_BY_TASK = {
"review_pr": "review",
"reconcile_already_landed": None,
"author_issue": "author",
"work_issue": "author",
"issue_filing": "issue_filing",
"issue_selection": None,
"inventory": "inventory",
}
_LEGACY_WORKSPACE_MUTATIONS_RE = re.compile(
r"^\s*[-*]?\s*workspace\s+mutations\s*:",
re.IGNORECASE | re.MULTILINE,
)
_VAGUE_MUTATIONS_NONE_RE = re.compile(
r"^\s*[-*]?\s*mutations\s*:\s*none\s*$",
re.IGNORECASE | re.MULTILINE,
)
_BARE_PYTEST_RE = re.compile(r"\b(?:pytest|python\s+-m\s+pytest)\b", re.IGNORECASE)
_VALIDATION_ENV_RE = re.compile(
r"(?:working directory|cwd|executed (?:in|from)|command\s+path)\s*:|\bin\s+branches/",
re.IGNORECASE,
)
_MAIN_CHECKOUT_RE = re.compile(
r"(?:/Gitea-Tools(?:/|$)(?!branches/)|main checkout|shared checkout|control checkout)",
re.IGNORECASE,
)
_SAME_AS_MASTER_RE = re.compile(
r"same as master|same failures as master|matches master baseline",
re.IGNORECASE,
)
_BASELINE_WORKTREE_RE = re.compile(
r"baseline worktree",
re.IGNORECASE,
)
_APPROVAL_CLAIM_RE = re.compile(
r"(?:submitted\s+['\"]approve['\"]|review decision\s*:\s*approve|claims?\s+approve)",
re.IGNORECASE,
)
_FULL_SUITE_FAIL_RE = re.compile(
r"(?:full suite failed|full test suite failed|\d+\s+failed(?:,|\s))",
re.IGNORECASE,
)
_ALREADY_LANDED_RE = re.compile(
r"already[- ]landed|ancestor proof\s*:\s*passed|eligibility class\s*:\s*already_landed",
re.IGNORECASE,
)
_ELIGIBLE_REVIEW_RE = re.compile(
r"eligible for (?:review|merge)|ready for (?:review|merge)|next eligible pr",
re.IGNORECASE,
)
_REVIEWED_LANDED_RE = re.compile(
r"(?:reviewed|approved).{0,40}already[- ]landed|already[- ]landed.{0,40}(?:reviewed|eligible)",
re.IGNORECASE | re.DOTALL,
)
_RECONCILE_STALE_FIELDS = (
"pr number opened",
"pinned reviewed head",
"scratch worktree used",
"review decision",
"merge result",
)
_RECONCILE_REQUIRED_FIELDS = (
"Selected PR",
"Eligibility class",
"Linked issue live status",
"Safe next action",
"No review/merge confirmation",
)
_LINKED_ISSUE_STATUS_RE = re.compile(
r"linked issue(?:\s+live)?\s+status\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_LINKED_ISSUE_NUMBER_RE = re.compile(
r"linked issue\s*:\s*#?(\d+)",
re.IGNORECASE,
)
_INVENTORY_COMPLETE_RE = re.compile(
r"inventory\s+(?:complete|completeness\s*:\s*complete)",
re.IGNORECASE,
)
_PAGINATION_PROOF_RE = re.compile(
r"(?:pagination\s+complete|final page|no next page|total\s+(?:open\s+)?pr\s+count|traversed\s+all\s+pages)",
re.IGNORECASE,
)
_GIT_FETCH_RE = re.compile(r"\bgit\s+fetch\b", re.IGNORECASE)
_READONLY_DIAG_RE = re.compile(
r"read[- ]only diagnostics\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_MUTATION_CATEGORY_FIELDS = (
"file edits by reviewer",
"worktree/index mutations",
"git ref mutations",
"mcp/gitea mutations",
"reconciliation mutations",
"file edits by reconciler",
)
def _normalize_task_kind(task_kind: str | None) -> str:
raw = (task_kind or "").strip().lower().replace(" ", "_")
return _TASK_KIND_ALIASES.get(raw, raw)
def validator_finding(
rule_id: str,
severity: str,
field: str,
reason: str,
safe_next_action: str,
) -> dict[str, str]:
"""Structured finding for controller handoff consumption."""
return {
"rule_id": rule_id,
"severity": severity,
"field": field,
"reason": reason,
"safe_next_action": safe_next_action,
}
def _findings_from_reasons(
rule_id: str,
reasons: list[str],
*,
field: str = "",
severity: str = "downgrade",
safe_next_action: str = "downgrade final report; repair missing proof fields",
) -> list[dict[str, str]]:
return [
validator_finding(rule_id, severity, field, reason, safe_next_action)
for reason in reasons
]
def _handoff_fields(report_text: str) -> dict[str, str]:
from review_proofs import _handoff_section_lines # local import avoids cycle at load
section = _handoff_section_lines(report_text) or []
fields: dict[str, str] = {}
for line in section:
stripped = line.strip().lstrip("-*").strip()
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
fields[key.strip().lower()] = value.strip()
return fields
def _rule_shared_controller_handoff(
report_text: str,
task_kind: str,
*,
local_edits: bool = False,
) -> list[dict[str, str]]:
role = _HANDOFF_ROLE_BY_TASK.get(task_kind)
result = assess_controller_handoff(
report_text,
role=role,
local_edits=local_edits,
)
verdict = result.get("verdict")
if verdict == "complete":
return []
severity = "block" if verdict == "missing" else "downgrade"
return _findings_from_reasons(
"shared.controller_handoff",
result.get("reasons") or [],
field="Controller Handoff",
severity=severity,
safe_next_action=(
"add a complete Controller Handoff section with task-appropriate fields"
if verdict == "missing"
else "fill missing controller handoff fields before submission"
),
)
def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]:
result = assess_email_disclosure(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"shared.email_disclosure",
result.get("reasons") or [],
field="Identity",
severity="downgrade",
safe_next_action="use username / profile identity; remove personal email",
)
def _rule_reviewer_legacy_workspace_mutations(
report_text: str,
*,
mutations_observed: bool = False,
) -> list[dict[str, str]]:
if not mutations_observed:
return []
if not _LEGACY_WORKSPACE_MUTATIONS_RE.search(report_text or ""):
return []
return [
validator_finding(
"reviewer.legacy_workspace_mutations",
"block",
"Workspace mutations",
"legacy 'Workspace mutations' field used after observed mutations; "
"use precise mutation categories instead",
"replace Workspace mutations with file/worktree/git/MCP category fields",
)
]
def _rule_reviewer_vague_mutations_none(
report_text: str,
*,
action_log: list[dict] | None = None,
mutations_observed: bool = False,
) -> list[dict[str, str]]:
performed = any(
e.get("performed") is not False and not e.get("gated_rejected")
for e in (action_log or [])
)
if not (mutations_observed or performed):
return []
if not _VAGUE_MUTATIONS_NONE_RE.search(report_text or ""):
return []
return [
validator_finding(
"reviewer.vague_mutations_none",
"block",
"Mutations",
"report claims 'Mutations: none' while mutations were observed",
"list performed mutations under precise category fields",
)
]
def _rule_reviewer_mutation_categories(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
lower = text.lower()
if "mutations:" not in lower and "mutation categories" not in lower:
return [
validator_finding(
"reviewer.mutation_categories",
"downgrade",
"Mutations",
"reviewer handoff lacks precise mutation category fields",
"report file edits, worktree/index, git ref, MCP/Gitea, and review mutations separately",
)
]
if any(term in lower for term in ("workspace mutations", "mutations: none")):
return []
if any(field in lower for field in _MUTATION_CATEGORY_FIELDS):
return []
vague_only = "mutations:" in lower and not any(
marker in lower
for marker in ("file edits", "worktree", "git ref", "mcp/gitea", "review mutation")
)
if vague_only:
return [
validator_finding(
"reviewer.mutation_categories",
"downgrade",
"Mutations",
"mutation section is vague; precise categories are required",
"split mutations into file edits, worktree/index, git ref, MCP/Gitea, review",
)
]
return []
def _rule_reviewer_git_fetch_readonly(
report_text: str,
*,
action_log: list[dict] | None = None,
) -> list[dict[str, str]]:
text = report_text or ""
fetch_observed = any(
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
for e in (action_log or [])
) or _GIT_FETCH_RE.search(text)
if not fetch_observed:
return []
readonly_match = _READONLY_DIAG_RE.search(text)
readonly_value = (readonly_match.group(1) if readonly_match else "").strip().lower()
fields = _handoff_fields(text)
git_ref_value = fields.get("git ref mutations", "").strip().lower()
git_ref_reported = bool(git_ref_value) and git_ref_value not in {
"none",
"n/a",
"no",
}
if git_ref_reported:
return []
if readonly_match and _GIT_FETCH_RE.search(readonly_value):
return [
validator_finding(
"reviewer.git_fetch_readonly",
"block",
"Git ref mutations",
"git fetch occurred but was classified only as read-only diagnostics",
"classify git fetch under Git ref mutations, not read-only diagnostics",
)
]
if not git_ref_reported and _GIT_FETCH_RE.search(text):
return [
validator_finding(
"reviewer.git_fetch_readonly",
"downgrade",
"Git ref mutations",
"git fetch mentioned without Git ref mutation classification",
"record git fetch under Git ref mutations",
)
]
return []
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _BARE_PYTEST_RE.search(text):
return []
if _VALIDATION_ENV_RE.search(text):
return []
return [
validator_finding(
"reviewer.validation_command_proof",
"downgrade",
"Validation",
"bare pytest command without working-directory or executable path proof",
"report exact validation command, cwd, and path proof for bare commands",
)
]
def _rule_reviewer_linked_issue(
report_text: str,
*,
linked_issue_lock: dict | None = None,
) -> list[dict[str, str]]:
lock = linked_issue_lock or {}
expected_issue = lock.get("issue_number")
expected_pr = lock.get("pr_number")
if expected_issue is None and expected_pr is None:
return []
text = report_text or ""
findings: list[dict[str, str]] = []
reported_issue = None
issue_match = _LINKED_ISSUE_NUMBER_RE.search(text)
if issue_match:
reported_issue = int(issue_match.group(1))
elif expected_issue is not None:
if f"#{expected_issue}" not in text and f"issue {expected_issue}" not in text.lower():
findings.append(
validator_finding(
"reviewer.linked_issue_missing",
"downgrade",
"Linked issue",
f"linked issue #{expected_issue} not documented in final report",
"document the linked issue number matching the selected PR",
)
)
if expected_issue is not None and reported_issue is not None and reported_issue != expected_issue:
findings.append(
validator_finding(
"reviewer.linked_issue_mismatch",
"block",
"Linked issue",
f"reported linked issue #{reported_issue} does not match "
f"selected PR linked issue #{expected_issue}",
"reconcile linked issue proof with the selected PR before reporting status",
)
)
if expected_pr is not None:
pr_tokens = (f"pr #{expected_pr}", f"pr{expected_pr}", f"#pr{expected_pr}")
if not any(token in text.lower().replace(" ", "") for token in pr_tokens):
if f"#{expected_pr}" not in text:
findings.append(
validator_finding(
"reviewer.linked_pr_missing",
"downgrade",
"Selected PR",
f"selected PR #{expected_pr} not consistently referenced",
"reference the selected PR number in handoff and diagnostics",
)
)
return findings
def _rule_reviewer_baseline_on_failure(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not (_APPROVAL_CLAIM_RE.search(text) and _FULL_SUITE_FAIL_RE.search(text)):
return []
if _BASELINE_WORKTREE_RE.search(text) and "baseline worktree path" in text.lower():
return []
return [
validator_finding(
"reviewer.baseline_on_full_suite_failure",
"block",
"Baseline worktree",
"approval claimed after full-suite failure without baseline worktree proof",
"create a clean branches/ baseline worktree and report path plus results",
)
]
def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _SAME_AS_MASTER_RE.search(text):
return []
if _BASELINE_WORKTREE_RE.search(text):
return []
return [
validator_finding(
"reviewer.main_checkout_baseline",
"block",
"Baseline worktree",
"vague 'same as master' claim without clean baseline worktree proof",
"validate against a clean branches/ baseline worktree; do not use main checkout",
)
]
def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if "baseline worktree path" not in text.lower():
return []
fields = _handoff_fields(text)
baseline_path = ""
for key, value in fields.items():
if key.startswith("baseline worktree path"):
baseline_path = value
break
if baseline_path and _MAIN_CHECKOUT_RE.search(baseline_path):
return [
validator_finding(
"reviewer.main_checkout_path",
"block",
"Baseline worktree path",
"baseline validation used main/shared checkout instead of branches/ worktree",
"recreate baseline proof under branches/ and report that path",
)
]
return []
def _rule_reviewer_already_landed_eligible(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _ALREADY_LANDED_RE.search(text):
return []
if not _ELIGIBLE_REVIEW_RE.search(text):
return []
return [
validator_finding(
"reviewer.already_landed_eligible_wording",
"block",
"Eligibility class",
"already-landed PR described as eligible for review or merge",
"classify as ALREADY_LANDED_RECONCILE_REQUIRED and stop normal review",
)
]
def _rule_reconcile_controller_handoff(report_text: str) -> list[dict[str, str]]:
from review_proofs import _handoff_section_lines
if _handoff_section_lines(report_text) is None:
return [
validator_finding(
"reconcile.controller_handoff",
"block",
"Controller Handoff",
f"final report has no section titled exactly '{HANDOFF_HEADING}'",
"add a reconciliation Controller Handoff section",
)
]
fields = _handoff_fields(report_text)
missing = [
name
for name in _RECONCILE_REQUIRED_FIELDS
if not (fields.get(name.lower()) or "").strip()
]
if missing:
return [
validator_finding(
"reconcile.controller_handoff",
"downgrade",
"Controller Handoff",
f"reconciliation handoff missing required field: {field}",
"complete the reconciliation handoff schema before submission",
)
for field in missing
]
return []
def _rule_reconcile_stale_author_fields(report_text: str) -> list[dict[str, str]]:
text = (report_text or "").lower()
findings = []
for field in _RECONCILE_STALE_FIELDS:
if f"{field}:" in text:
findings.append(
validator_finding(
"reconcile.stale_author_reviewer_fields",
"block",
field,
f"reconciliation handoff includes stale author/reviewer field '{field}'",
"use reconciliation schema only; remove author/reviewer review fields",
)
)
return findings
def _rule_reconcile_eligible_reviewed(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _ALREADY_LANDED_RE.search(text):
return []
findings = []
if _ELIGIBLE_REVIEW_RE.search(text):
findings.append(
validator_finding(
"reconcile.eligible_reviewed_wording",
"block",
"Eligibility class",
"already-landed PR described as eligible for review or merge",
"report ALREADY_LANDED_RECONCILE_REQUIRED and reconciliation-only next steps",
)
)
if _REVIEWED_LANDED_RE.search(text):
findings.append(
validator_finding(
"reconcile.reviewed_landed_wording",
"block",
"Current status",
"already-landed PR described as reviewed or approval-eligible",
"use reconciliation status wording only",
)
)
return findings
def _rule_reconcile_linked_issue_live(
report_text: str,
*,
linked_issue_lock: dict | None = None,
) -> list[dict[str, str]]:
lock = linked_issue_lock or {}
text = report_text or ""
status_match = _LINKED_ISSUE_STATUS_RE.search(text)
if not status_match:
return []
status_value = status_match.group(1).strip().lower()
if "not verified" in status_value:
return []
live_proof = lock.get("live_fetched") is True or lock.get("verified_live") is True
if live_proof:
return []
if status_value not in {"", "none", "n/a", "unknown"}:
return [
validator_finding(
"reconcile.linked_issue_live_status",
"downgrade",
"Linked issue live status",
"linked issue status reported without live verification proof in this session",
"fetch linked issue live or report 'not verified in this session'",
)
]
return []
def _rule_reconcile_pagination_proof(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _INVENTORY_COMPLETE_RE.search(text):
return []
if _PAGINATION_PROOF_RE.search(text):
return []
return [
validator_finding(
"reconcile.inventory_pagination_proof",
"downgrade",
"PR inventory",
"inventory completeness claimed without pagination or final-page proof",
"include pagination complete, final page, or total open PR count evidence",
)
]
def _rule_reviewer_validation_structured(
validation_report: dict | None,
) -> list[dict[str, str]]:
if not validation_report:
return []
result = assess_validation_report(validation_report)
if result.get("verdict") == "strong":
return []
severity = "block" if result.get("verdict") == "invalid" else "downgrade"
return _findings_from_reasons(
"reviewer.validation_structured",
result.get("reasons") or [],
field="Validation",
severity=severity,
safe_next_action="provide exact validation command, output read proof, and counts",
)
def _rule_reviewer_mutation_ledger(
report_text: str,
*,
action_log: list[dict] | None = None,
) -> list[dict[str, str]]:
if not action_log:
return []
result = assess_mutation_ledger_report(report_text, action_log=action_log)
if result.get("proven"):
return []
return _findings_from_reasons(
"reviewer.mutation_ledger",
result.get("reasons") or [],
field="File edits by reviewer",
severity="block",
safe_next_action="align mutation ledger with observed file edits",
)
def _rule_reviewer_review_mutation(
report_text: str,
*,
review_decision_lock: dict | None = None,
) -> list[dict[str, str]]:
if not review_decision_lock:
return []
result = assess_review_mutation_final_report(report_text, review_decision_lock)
if result.get("complete"):
return []
return _findings_from_reasons(
"reviewer.review_mutation_proof",
result.get("reasons") or [],
field="Review mutations",
severity="downgrade",
safe_next_action="document exactly one live review mutation or authorized correction flow",
)
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"review_pr": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
_rule_reviewer_mutation_categories,
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_validation_command,
_rule_reviewer_validation_structured,
_rule_reviewer_linked_issue,
_rule_reviewer_baseline_on_failure,
_rule_reviewer_main_checkout_baseline,
_rule_reviewer_main_checkout_path,
_rule_reviewer_already_landed_eligible,
_rule_reviewer_mutation_ledger,
_rule_reviewer_review_mutation,
],
"reconcile_already_landed": [
_rule_reconcile_controller_handoff,
_rule_shared_email_disclosure,
_rule_reconcile_stale_author_fields,
_rule_reconcile_eligible_reviewed,
_rule_reconcile_linked_issue_live,
_rule_reconcile_pagination_proof,
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
],
"author_issue": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
_rule_reviewer_vague_mutations_none,
],
"work_issue": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
_rule_reviewer_vague_mutations_none,
],
"issue_filing": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
],
"inventory": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
_rule_reconcile_pagination_proof,
],
"issue_selection": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
],
}
def _aggregate_grade(findings: list[dict[str, str]]) -> tuple[str, bool, bool]:
blocked = any(f["severity"] == "block" for f in findings)
downgraded = any(f["severity"] == "downgrade" for f in findings)
if blocked:
return "blocked", True, downgraded
if downgraded:
return "downgraded", False, True
return "A", False, False
def _primary_safe_next_action(findings: list[dict[str, str]]) -> str:
for severity in ("block", "downgrade", "warning"):
for finding in findings:
if finding["severity"] == severity:
return finding["safe_next_action"]
return "proceed"
def _call_rule(
rule: Callable[..., list[dict[str, str]]],
report_text: str,
task_kind: str,
rule_kwargs: dict[str, Any],
) -> list[dict[str, str]]:
if rule is _rule_shared_controller_handoff:
return rule(
report_text,
task_kind,
local_edits=bool(rule_kwargs.get("local_edits")),
)
if rule is _rule_reviewer_validation_structured:
return rule(rule_kwargs.get("validation_report"))
if rule is _rule_reconcile_controller_handoff:
return rule(report_text)
bound: dict[str, Any] = {}
for name, param in inspect.signature(rule).parameters.items():
if name == "report_text":
bound[name] = report_text
elif name in rule_kwargs:
bound[name] = rule_kwargs[name]
elif param.default is inspect.Parameter.empty and param.kind != inspect.Parameter.VAR_KEYWORD:
continue
return rule(**bound)
def assess_final_report_validator(
report_text: str,
task_kind: str,
*,
review_decision_lock: dict | None = None,
linked_issue_lock: dict | None = None,
validation_report: dict | None = None,
action_log: list[dict] | None = None,
mutations_observed: bool = False,
local_edits: bool = False,
issue_filing_lock: dict | None = None,
) -> dict[str, Any]:
"""Validate final-report text against task-specific proof rules (#327).
Returns structured findings plus a composite grade suitable for workflow
controllers. Optional *proof_locks* carry live session facts used to fail
closed on stale or contradictory report fields.
"""
normalized_kind = _normalize_task_kind(task_kind)
if normalized_kind not in FINAL_REPORT_TASK_KINDS:
return {
"task_kind": normalized_kind,
"grade": "blocked",
"blocked": True,
"downgraded": False,
"findings": [
validator_finding(
"shared.unknown_task_kind",
"block",
"Task",
f"unknown task kind '{task_kind}'",
"use a supported task kind from FINAL_REPORT_TASK_KINDS",
)
],
"checks": {},
"reasons": [f"unknown task kind '{task_kind}'"],
"safe_next_action": "set task_kind to a supported workflow mode",
"complete": False,
}
checks: dict[str, Any] = {}
findings: list[dict[str, str]] = []
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
checks["issue_filing"] = assess_issue_filing_final_report(
report_text,
**issue_filing_lock,
)
if checks["issue_filing"].get("downgraded"):
findings.extend(
_findings_from_reasons(
"issue_filing.composite",
checks["issue_filing"].get("reasons") or [],
field="Issue filing",
severity="downgrade",
)
)
rule_kwargs = {
"review_decision_lock": review_decision_lock,
"linked_issue_lock": linked_issue_lock,
"validation_report": validation_report,
"action_log": action_log,
"mutations_observed": mutations_observed,
"local_edits": local_edits,
}
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
findings.extend(_call_rule(rule, report_text, normalized_kind, rule_kwargs))
grade, blocked, downgraded = _aggregate_grade(findings)
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
return {
"task_kind": normalized_kind,
"grade": grade,
"blocked": blocked,
"downgraded": downgraded,
"findings": findings,
"checks": checks,
"reasons": reasons,
"safe_next_action": _primary_safe_next_action(findings),
"complete": grade == "A",
"handoff_heading": HANDOFF_HEADING,
}
+37
View File
@@ -420,6 +420,43 @@ def api_get_all(url, auth_header, *, limit=None, page_size=50, max_pages=100,
return results
def api_fetch_page(url, auth_header, *, page=1, limit=50, **kwargs):
"""Fetch one page from a Gitea list endpoint with explicit pagination metadata.
Returns ``(items, pagination)`` where *pagination* includes ``has_more``,
``next_page``, and ``is_final_page`` derived from the returned page length.
"""
page = max(1, int(page))
limit = max(1, min(50, int(limit)))
page_url = _add_query(url, page=page, limit=limit)
data = api_request("GET", page_url, auth_header, **kwargs)
if data is None:
pagination = {
"page": page,
"per_page": limit,
"returned_count": 0,
"has_more": False,
"next_page": None,
"is_final_page": True,
}
return [], pagination
if not isinstance(data, list):
raise RuntimeError(
f"expected a list page from Gitea, got {type(data).__name__}"
)
returned = len(data)
has_more = returned >= limit
pagination = {
"page": page,
"per_page": limit,
"returned_count": returned,
"has_more": has_more,
"next_page": page + 1 if has_more else None,
"is_final_page": not has_more,
}
return data, pagination
def gitea_url(host, path):
"""Build a full URL for *host* and *path*, using http for loopback and https for others."""
if not path.startswith("/"):
+9 -1
View File
@@ -205,7 +205,7 @@ def selected_profile_name():
def is_runtime_switching_enabled(path=None):
"""Check if runtime profile switching is explicitly enabled in config."""
"""Check if runtime profile switching is enabled in config."""
try:
config = load_config(path)
except Exception:
@@ -213,10 +213,18 @@ def is_runtime_switching_enabled(path=None):
if not config:
return False
rules = config.get("rules") or {}
if rules.get("allow_runtime_switching") is False:
return False
if config.get("allow_runtime_switching") is False:
return False
if rules.get("allow_runtime_switching") is True:
return True
if config.get("allow_runtime_switching") is True:
return True
# Default to True if multiple profiles exist in the config
profiles = config.get("profiles") or {}
if len(profiles) > 1:
return True
return False
+1331 -125
View File
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
"""Issue-lock worktree validation (#249).
Author issue locks must validate the caller's own scratch clone (or declared
worktree path), not the shared MCP server working directory. A clean scratch at
``master``/``main`` must remain lockable while an unrelated session leaves the
shared dev worktree dirty or on a feature branch.
"""
from __future__ import annotations
import os
import subprocess
from reviewer_worktree import parse_dirty_tracked_files
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
BASE_BRANCHES = frozenset({"master", "main", "dev"})
def resolve_author_worktree_path(
explicit: str | None,
project_root: str,
) -> str:
"""Resolve the author worktree path for lock/PR gates."""
path = (explicit or "").strip()
if not path:
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
if not path:
path = project_root
return os.path.realpath(os.path.abspath(path))
def read_worktree_git_state(worktree_path: str) -> dict:
"""Read branch name and porcelain status from a git worktree."""
path = (worktree_path or "").strip()
if not path:
return {"current_branch": None, "porcelain_status": ""}
branch_res = subprocess.run(
["git", "-C", path, "branch", "--show-current"],
capture_output=True,
text=True,
check=False,
)
current_branch = (branch_res.stdout or "").strip() or None
status_res = subprocess.run(
["git", "-C", path, "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
root_res = subprocess.run(
["git", "-C", path, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
head_res = subprocess.run(
["git", "-C", path, "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
)
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
base_branch, base_sha = _find_matching_base_ref(path, head_sha)
return {
"current_branch": current_branch,
"porcelain_status": status_res.stdout or "",
"inspected_git_root": (root_res.stdout or "").strip() if root_res.returncode == 0 else None,
"head_sha": head_sha,
"base_branch": base_branch,
"base_sha": base_sha,
"base_equivalent": bool(head_sha and base_sha and head_sha == base_sha),
}
def assess_issue_lock_worktree(
*,
worktree_path: str,
current_branch: str | None,
porcelain_status: str,
base_equivalent: bool | None = None,
inspected_git_root: str | None = None,
base_branch: str | None = None,
base_branches: frozenset[str] | None = None,
) -> dict:
"""Fail closed when lock preconditions are not met on the declared worktree."""
bases = base_branches or BASE_BRANCHES
reasons: list[str] = []
path = (worktree_path or "").strip()
if not path:
reasons.append("worktree path not declared for issue lock; fail closed")
return _assessment(False, reasons, path, None, [])
branch = (current_branch or "").strip()
dirty_files = parse_dirty_tracked_files(porcelain_status)
if dirty_files:
reasons.append(
"tracked file edits exist before issue lock; "
f"lock must precede implementation work in '{path}' "
f"(dirty files: {', '.join(dirty_files)})"
)
if base_equivalent is False:
reasons.append(
"issue lock worktree must be base-equivalent to one of "
f"{_base_list(bases)} before implementation work; inspected "
f"branch '{branch or '(detached)'}' at '{path}'"
)
elif base_equivalent is None:
if not branch:
reasons.append(
"current branch unknown (detached HEAD?); issue lock base-equivalence "
f"to {_base_list(bases)} could not be proven"
)
elif branch not in bases:
reasons.append(
"issue lock worktree base-equivalence could not be proven; "
f"branch '{branch}' is not {_base_list(bases)}"
)
proven = not reasons
return _assessment(
proven,
reasons,
path,
branch or None,
dirty_files,
inspected_git_root=inspected_git_root,
base_branch=base_branch,
base_equivalent=base_equivalent,
)
def format_issue_lock_worktree_error(assessment: dict) -> str:
"""Format a single fail-closed error for ``gitea_lock_issue``."""
reasons = list(assessment.get("reasons") or [])
if not reasons:
reasons = ["issue lock worktree validation failed"]
return "; ".join(reasons) + " (fail closed)"
def verify_pr_worktree_matches_lock(
locked_worktree_path: str | None,
declared_worktree_path: str | None,
project_root: str,
) -> dict:
"""PR creation must use the same worktree the lock was validated against."""
locked = (locked_worktree_path or "").strip()
if not locked:
return {"proven": True, "block": False, "reasons": []}
declared = resolve_author_worktree_path(declared_worktree_path, project_root)
locked_real = os.path.realpath(locked)
declared_real = os.path.realpath(declared)
if locked_real != declared_real:
return {
"proven": False,
"block": True,
"reasons": [
f"PR worktree '{declared_real}' does not match locked worktree "
f"'{locked_real}' (fail closed)"
],
"locked_worktree_path": locked_real,
"declared_worktree_path": declared_real,
}
return {
"proven": True,
"block": False,
"reasons": [],
"locked_worktree_path": locked_real,
"declared_worktree_path": declared_real,
}
def _base_list(bases: frozenset[str]) -> str:
return "/".join(sorted(bases))
def _assessment(
proven: bool,
reasons: list[str],
worktree_path: str,
current_branch: str | None,
dirty_files: list[str],
*,
inspected_git_root: str | None = None,
base_branch: str | None = None,
base_equivalent: bool | None = None,
) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"worktree_path": worktree_path or None,
"inspected_git_root": inspected_git_root,
"current_branch": current_branch,
"dirty_files": dirty_files,
"base_branch": base_branch,
"base_equivalent": base_equivalent,
}
def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
"""Return the stable branch ref whose commit matches HEAD, if any."""
if not head_sha:
return None, None
candidates: list[str] = []
for branch in sorted(BASE_BRANCHES):
candidates.extend((f"origin/{branch}", branch))
for ref in candidates:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", ref],
capture_output=True,
text=True,
check=False,
)
sha = (res.stdout or "").strip()
if res.returncode == 0 and sha == head_sha:
return ref, sha
return None, None
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""MCP Discoverability Validation Tool for external servers (Issue #155)."""
import os
import sys
import json
import argparse
import subprocess
EXPECTED_JENKINS_TOOLS = {
"jenkins_whoami",
"jenkins_list_jobs",
"jenkins_latest_build",
"jenkins_build_status",
"jenkins_get_build",
}
EXPECTED_GLITCHTIP_TOOLS = {
"glitchtip_whoami",
"glitchtip_list_projects",
"glitchtip_list_unresolved",
"glitchtip_get_issue",
"glitchtip_recent_events",
"glitchtip_search",
}
RELOAD_INSTRUCTIONS = """
=== MCP CLIENT RELOAD/RECONNECT RUNBOOK ===
After registering or changing external MCP servers, reload your client to discover the new tools:
- Codex: Click 'Reload Developer Tools' or restart the editor.
- Gemini / Grok / ChatGPT Desktop: Restart the client or run the reload slash command if available.
- Claude Desktop: Use 'Developer -> Reload' or restart the app.
- General MCP Clients: Restart the process or reload the server config.
===========================================
"""
def parse_gitea_mcp_config(path):
if not path or not os.path.exists(path):
return {}
with open(path, "r", encoding="utf-8") as fh:
try:
return json.load(fh)
except Exception:
return {}
def read_json_rpc_response(proc, req_id):
import time
start_time = time.time()
while time.time() - start_time < 5.0:
line = proc.stdout.readline()
if not line:
break
try:
data = json.loads(line)
if data.get("id") == req_id:
return data
except json.JSONDecodeError:
continue
return None
def query_live_tools(command, args, env):
run_env = os.environ.copy()
if env:
run_env.update(env)
proc = subprocess.Popen(
[command] + args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=run_env,
text=True,
bufsize=1
)
tools = []
try:
# 1. Send initialize
init_req = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "mcp-discoverability-check", "version": "1.0.0"}
}
}
proc.stdin.write(json.dumps(init_req) + "\n")
proc.stdin.flush()
# Read init response
init_resp = read_json_rpc_response(proc, 1)
if init_resp:
# Send initialized notification
init_notif = {
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
proc.stdin.write(json.dumps(init_notif) + "\n")
proc.stdin.flush()
# 2. Send tools/list
tools_req = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}
proc.stdin.write(json.dumps(tools_req) + "\n")
proc.stdin.flush()
tools_resp = read_json_rpc_response(proc, 2)
if tools_resp and "result" in tools_resp and "tools" in tools_resp["result"]:
for t in tools_resp["result"]["tools"]:
tools.append(t["name"])
except Exception as e:
print(f"Error querying live tools: {e}", file=sys.stderr)
finally:
proc.terminate()
try:
proc.wait(timeout=2)
except Exception:
proc.kill()
return set(tools)
def validate_mcp_client_config(client_config_path, gitea_config_path=None, live=False):
if not client_config_path or not os.path.exists(client_config_path):
print(f"SKIPPED: MCP client config not found at '{client_config_path}'", file=sys.stderr)
return True
with open(client_config_path, "r", encoding="utf-8") as fh:
try:
config_data = json.load(fh)
except Exception as e:
print(f"Error parsing client config: {e}", file=sys.stderr)
return False
mcp_servers = config_data.get("mcpServers", {})
# Check for stale server names
stale_names = {"jenkins-readonly", "glitchtip-readonly"}
for name in mcp_servers:
if name in stale_names:
print(f"ERROR: Stale server name '{name}' configured. Use canonical names 'jenkins-mcp' or 'glitchtip-mcp'.", file=sys.stderr)
return False
gitea_data = parse_gitea_mcp_config(gitea_config_path)
enabled_services = set()
contexts = gitea_data.get("contexts", {})
profile_name = os.environ.get("GITEA_MCP_PROFILE")
if profile_name and "profiles" in gitea_data:
profile = gitea_data["profiles"].get(profile_name)
if profile and "context" in profile:
ctx_name = profile["context"]
ctx = contexts.get(ctx_name, {})
if ctx.get("enabled"):
services = ctx.get("services", {})
for s_name, s_data in services.items():
if s_data.get("enabled"):
enabled_services.add(s_name)
else:
for ctx_name, ctx in contexts.items():
if ctx.get("enabled"):
services = ctx.get("services", {})
for s_name, s_data in services.items():
if s_data.get("enabled"):
enabled_services.add(s_name)
if not enabled_services:
print("No external services enabled in Gitea contexts. Discoverability check complete.", file=sys.stderr)
return True
success = True
for service in enabled_services:
canonical_name = f"{service}-mcp"
if canonical_name not in mcp_servers:
stale_match = f"{service}-readonly"
if stale_match in mcp_servers:
print(f"ERROR: Server '{canonical_name}' references stale name '{stale_match}' (fail closed).", file=sys.stderr)
success = False
continue
print(f"ERROR: Enabled service '{service}' is not registered under canonical name '{canonical_name}' in client config.", file=sys.stderr)
success = False
continue
server_conf = mcp_servers[canonical_name]
command = server_conf.get("command")
args = server_conf.get("args") or []
env = server_conf.get("env") or {}
if not command:
print(f"ERROR: Server '{canonical_name}' has no command configured.", file=sys.stderr)
success = False
continue
expected_module = f"{service}_mcp"
if "-m" not in args or expected_module not in args:
print(f"ERROR: Server '{canonical_name}' args do not point to expected module '{expected_module}' (args: {args}).", file=sys.stderr)
success = False
continue
profile_var = f"{service.upper()}_MCP_PROFILE"
config_var = f"{service.upper()}_MCP_CONFIG"
if profile_var not in env or config_var not in env:
print(f"ERROR: Server '{canonical_name}' env is missing required variables '{profile_var}' or '{config_var}'.", file=sys.stderr)
success = False
continue
if live:
tools = query_live_tools(command, args, env)
if not tools:
print("SKIPPED: server enabled but no usable tools visible", file=sys.stdout)
success = False
continue
expected = EXPECTED_JENKINS_TOOLS if service == "jenkins" else EXPECTED_GLITCHTIP_TOOLS
missing = expected - tools
if missing:
print(f"ERROR: Server '{canonical_name}' is missing expected tools: {', '.join(missing)}", file=sys.stderr)
success = False
else:
print(f"SUCCESS: Server '{canonical_name}' discoverability verified.", file=sys.stderr)
else:
print(f"SUCCESS: Server '{canonical_name}' static registration verified.", file=sys.stderr)
return success
def main():
parser = argparse.ArgumentParser(description="MCP client registration discoverability checks.")
parser.add_argument("--client-config", help="Path to MCP client config JSON file.")
parser.add_argument("--gitea-config", help="Path to Gitea MCP config JSON file.")
parser.add_argument("--live", action="store_true", help="Perform live stdio checks on configured servers.")
parser.add_argument("--runbook", action="store_true", help="Print reload/reconnect guide runbook instructions.")
args = parser.parse_args()
if args.runbook:
print(RELOAD_INSTRUCTIONS)
return 0
if not args.client_config:
print("ERROR: --client-config must be specified.", file=sys.stderr)
return 2
ok = validate_mcp_client_config(
client_config_path=args.client_config,
gitea_config_path=args.gitea_config,
live=args.live
)
if not ok:
print(RELOAD_INSTRUCTIONS, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+8 -10
View File
@@ -6,26 +6,24 @@ Runs over stdio. All tools authenticate via macOS keychain (git credential fill)
import os
import sys
from role_session_router import (
python_bytes_have_conflict_markers,
skip_python_scan_walk_root,
)
# Startup health check: scan all python files in the Gitea-Tools directory for unresolved conflict markers.
def check_conflict_markers():
dir_path = os.path.dirname(os.path.abspath(__file__))
# Construct conflict patterns dynamically so the loader does not match itself
conflict_patterns = [
b"<" * 7 + b" ",
b"=" * 7 + b"\n",
b"=" * 7 + b"\r\n",
b">" * 7 + b" "
]
for root, dirs, files in os.walk(dir_path):
if any(p in root for p in ("venv", ".git", ".pytest_cache", "branches")):
if skip_python_scan_walk_root(dir_path, root):
continue
for file in files:
if file.endswith(".py"):
file_path = os.path.join(root, file)
try:
with open(file_path, "rb") as f:
content = f.read()
if any(pattern in content for pattern in conflict_patterns):
if python_bytes_have_conflict_markers(f.read()):
rel_path = os.path.relpath(file_path, dir_path)
print(
f"infra_stop: Unresolved merge conflict detected in {rel_path}. "
+333
View File
@@ -0,0 +1,333 @@
"""Merged PR branch and worktree cleanup reconciliation (#269).
Builds dry-run reports for merged pull requests and optionally executes
remote branch deletion and local worktree removal when every safety gate passes.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
from typing import Any
from reviewer_worktree import parse_dirty_tracked_files
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
def extract_linked_issue(title: str, body: str) -> int | None:
"""Return the first Closes/Fixes issue number from PR metadata."""
for text in (title or "", body or ""):
match = CLOSES_FIXES_RE.search(text)
if match:
return int(match.group(1))
return None
def branch_worktree_folder(branch: str) -> str:
return (branch or "").replace("/", "-")
def resolve_worktree_path(project_root: str, branch: str) -> str:
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
lock_path = (path or ISSUE_LOCK_FILE).strip()
if not lock_path or not os.path.exists(lock_path):
return None
try:
with open(lock_path, encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
lock = read_issue_lock(lock_path)
if not lock:
return False
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
heads: set[str] = set()
for pr in open_prs or []:
head = pr.get("head")
if isinstance(head, dict):
ref = head.get("ref")
else:
ref = head
if ref:
heads.add(str(ref))
return heads
def read_local_worktree_state(worktree_path: str) -> dict[str, Any]:
path = (worktree_path or "").strip()
if not path or not os.path.isdir(path):
return {
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
}
branch_res = subprocess.run(
["git", "-C", path, "branch", "--show-current"],
capture_output=True,
text=True,
check=False,
)
current_branch = (branch_res.stdout or "").strip() or None
status_res = subprocess.run(
["git", "-C", path, "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
dirty_files = parse_dirty_tracked_files(status_res.stdout or "")
head_res = subprocess.run(
["git", "-C", path, "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
)
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
return {
"exists": True,
"clean": not dirty_files,
"current_branch": current_branch,
"head_sha": head_sha,
"dirty_files": dirty_files,
}
def is_head_ancestor_of_ref(project_root: str, head_sha: str | None, base_ref: str) -> bool | None:
if not head_sha:
return None
res = subprocess.run(
["git", "-C", project_root, "merge-base", "--is-ancestor", head_sha, base_ref],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
return True
if res.returncode == 1:
return False
return None
def assess_remote_branch_cleanup(
*,
pr_number: int,
head_branch: str,
merged: bool,
remote_branch_exists: bool,
open_pr_heads: set[str],
protected_branches: frozenset[str] | None = None,
head_on_master: bool | None,
delete_capability_allowed: bool,
active_lock: bool,
) -> dict[str, Any]:
protected = protected_branches or PROTECTED_BRANCHES
reasons: list[str] = []
if not merged:
reasons.append("PR is not merged")
if not remote_branch_exists:
reasons.append("remote branch already absent")
if head_branch in protected:
reasons.append(f"branch '{head_branch}' is protected")
if head_branch in open_pr_heads:
reasons.append("an open PR still references this head branch")
if active_lock:
reasons.append("active issue lock references this branch")
if head_on_master is False:
reasons.append("PR head is not an ancestor of master")
if not delete_capability_allowed:
reasons.append("delete_branch capability is not allowed in the active profile")
safe = merged and remote_branch_exists and not reasons
return {
"pr_number": pr_number,
"head_branch": head_branch,
"remote_branch_exists": remote_branch_exists,
"safe_to_delete_remote": safe,
"block_reasons": reasons,
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
}
def assess_local_worktree_cleanup(
*,
pr_number: int,
head_branch: str,
merged: bool,
worktree_state: dict[str, Any],
active_lock: bool,
) -> dict[str, Any]:
reasons: list[str] = []
exists = bool(worktree_state.get("exists"))
if not merged:
reasons.append("PR is not merged")
if not exists:
reasons.append("local worktree not present")
if exists and worktree_state.get("clean") is False:
dirty = worktree_state.get("dirty_files") or []
reasons.append(
"local worktree has tracked edits"
+ (f" ({', '.join(dirty)})" if dirty else "")
)
if exists:
current_branch = worktree_state.get("current_branch")
if current_branch and current_branch != head_branch:
reasons.append(
f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
)
if active_lock:
reasons.append("active issue lock references this branch")
safe = merged and exists and not reasons
return {
"pr_number": pr_number,
"head_branch": head_branch,
"worktree_path": worktree_state.get("worktree_path"),
"worktree_exists": exists,
"worktree_clean": worktree_state.get("clean"),
"safe_to_remove_worktree": safe,
"block_reasons": reasons,
"recommended_action": "remove_local_worktree" if safe else "keep_local_worktree",
}
def build_pr_cleanup_entry(
*,
pr: dict[str, Any],
project_root: str,
open_pr_heads: set[str],
remote_branch_exists: bool,
head_on_master: bool | None,
delete_capability_allowed: bool,
issue_lock_path: str | None = None,
protected_branches: frozenset[str] | None = None,
) -> dict[str, Any]:
pr_number = int(pr["number"])
head_branch = pr.get("head") or ""
if isinstance(head_branch, dict):
head_branch = head_branch.get("ref") or ""
title = pr.get("title") or ""
body = pr.get("body") or ""
merged = bool(pr.get("merged_at"))
worktree_path = resolve_worktree_path(project_root, head_branch)
worktree_state = read_local_worktree_state(worktree_path)
worktree_state["worktree_path"] = worktree_path
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
remote = assess_remote_branch_cleanup(
pr_number=pr_number,
head_branch=head_branch,
merged=merged,
remote_branch_exists=remote_branch_exists,
open_pr_heads=open_pr_heads,
protected_branches=protected_branches,
head_on_master=head_on_master,
delete_capability_allowed=delete_capability_allowed,
active_lock=active_lock,
)
local = assess_local_worktree_cleanup(
pr_number=pr_number,
head_branch=head_branch,
merged=merged,
worktree_state=worktree_state,
active_lock=active_lock,
)
return {
"pr_number": pr_number,
"issue_number": extract_linked_issue(title, body),
"title": title,
"head_branch": head_branch,
"merge_commit_sha": pr.get("merge_commit_sha"),
"merged_at": pr.get("merged_at"),
"merged": merged,
"remote_branch": remote,
"local_worktree": local,
}
def build_reconciliation_report(
*,
project_root: str,
closed_prs: list[dict[str, Any]],
open_prs: list[dict[str, Any]],
remote_branch_exists: dict[str, bool],
head_on_master: dict[int, bool | None],
delete_capability_allowed: bool,
issue_lock_path: str | None = None,
protected_branches: frozenset[str] | None = None,
) -> dict[str, Any]:
open_heads = collect_open_pr_heads(open_prs)
entries: list[dict[str, Any]] = []
for pr in closed_prs or []:
if not pr.get("merged_at"):
continue
pr_number = int(pr["number"])
head = pr.get("head") or ""
if isinstance(head, dict):
head = head.get("ref") or ""
entries.append(
build_pr_cleanup_entry(
pr=pr,
project_root=project_root,
open_pr_heads=open_heads,
remote_branch_exists=bool(remote_branch_exists.get(head)),
head_on_master=head_on_master.get(pr_number),
delete_capability_allowed=delete_capability_allowed,
issue_lock_path=issue_lock_path,
protected_branches=protected_branches,
)
)
return {
"project_root": os.path.realpath(project_root),
"merged_pr_count": len(entries),
"entries": entries,
"dry_run": True,
"executed": False,
}
def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
worktree_path = resolve_worktree_path(project_root, branch)
if not os.path.isdir(worktree_path):
return {
"success": False,
"performed": False,
"message": f"worktree not found: {worktree_path}",
}
res = subprocess.run(
["git", "-C", project_root, "worktree", "remove", worktree_path],
capture_output=True,
text=True,
check=False,
)
if res.returncode != 0:
return {
"success": False,
"performed": False,
"message": (res.stderr or res.stdout or "worktree remove failed").strip(),
}
return {
"success": True,
"performed": True,
"message": f"removed worktree {worktree_path}",
"worktree_path": worktree_path,
}
+2947 -5
View File
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
"""Prior-blocker skip proof verifier for reviewer queue reports (#318)."""
from __future__ import annotations
import re
from typing import Any
_FULL_SHA = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_SHORT_SHA = re.compile(r"\b[0-9a-f]{7,39}\b", re.IGNORECASE)
_PR_NUMBER_RE = re.compile(r"(?:\bPR\s*#?|#)(\d+)\b", re.IGNORECASE)
_BLOCKING_DECISION_RE = re.compile(
r"(?:blocking review decision|review decision|blocking decision)\s*:\s*request[_ ]changes|"
r"request[_ ]changes",
re.IGNORECASE,
)
_BLOCKING_HEAD_RE = re.compile(
r"(?:blocking review head(?:\s+sha)?|blocker head(?:\s+sha)?|review head at blocker)",
re.IGNORECASE,
)
_HEAD_CHANGED_RE = re.compile(
r"(?:head (?:changed|unchanged)|head sha (?:changed|unchanged)|"
r"head changed (?:after|since) (?:the )?blocker|head unchanged since blocker)",
re.IGNORECASE,
)
_BLOCKER_REASON_RE = re.compile(
r"(?:reason (?:it )?remains blocked|remains blocked because|blocking category)",
re.IGNORECASE,
)
_LIVE_BLOCKER_PROOF_RE = re.compile(
r"(?:blocker revalidated live|live proof|gitea_get_pr_review_feedback|"
r"gitea_view_pr|review feedback fetched|current review state)",
re.IGNORECASE,
)
_BLOCKER_UNVERIFIED_RE = re.compile(r"\bBLOCKER_STATUS_UNVERIFIED\b")
def _pr_section(text: str, pr_number: int) -> str:
lines = (text or "").splitlines()
chunks: list[str] = []
capture = False
token = f"#{pr_number}"
for line in lines:
lower = line.lower()
if token in lower or f"pr {pr_number}" in lower or f"pr#{pr_number}" in lower.replace(" ", ""):
capture = True
chunks.append(line)
continue
if capture:
if _PR_NUMBER_RE.search(line) and token not in line.lower():
break
if line.strip() == "" and len(chunks) > 3:
break
chunks.append(line)
if chunks:
return "\n".join(chunks)
return text or ""
def _has_head_sha(text: str, head_sha: str | None) -> bool:
if not head_sha:
return bool(_FULL_SHA.search(text) or _SHORT_SHA.search(text))
head = head_sha.strip().lower()
if head in text.lower():
return True
if len(head) >= 7 and head[:7] in text.lower():
return True
return bool(_FULL_SHA.search(text))
def assess_prior_blocker_skip_proof(
report_text: str,
*,
skipped_prs: list[dict] | None = None,
) -> dict[str, Any]:
"""Validate live blocker proof for skipped earlier open PRs (#318)."""
text = report_text or ""
reasons: list[str] = []
assessments: list[dict[str, Any]] = []
for entry in skipped_prs or []:
pr_number = entry.get("pr_number")
if pr_number is None:
reasons.append("skipped PR entry missing pr_number")
continue
pr_number = int(pr_number)
section = _pr_section(text, pr_number)
verified = entry.get("blocker_verified")
head_changed = entry.get("head_changed_since_blocker")
blocking_head = (entry.get("blocking_review_head_sha") or "").strip() or None
current_head = (entry.get("head_sha") or "").strip() or None
skip_reason = (entry.get("skip_reason") or entry.get("blocking_decision") or "").lower()
item_reasons: list[str] = []
if head_changed is True:
item_reasons.append(
f"PR #{pr_number} head changed after blocker; it cannot be skipped "
"based on stale REQUEST_CHANGES"
)
if f"#{pr_number}" not in text.lower() and f"pr {pr_number}" not in text.lower():
item_reasons.append(f"skipped PR #{pr_number} not documented in final report")
if "request_changes" in skip_reason or entry.get("blocking_decision") == "request_changes":
if verified is False:
if not _BLOCKER_UNVERIFIED_RE.search(section):
item_reasons.append(
f"PR #{pr_number} blocker proof unavailable; report must classify "
"BLOCKER_STATUS_UNVERIFIED"
)
else:
if not _BLOCKING_DECISION_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing blocking review decision proof"
)
if not _has_head_sha(section, current_head):
item_reasons.append(
f"PR #{pr_number} skip missing current head SHA"
)
if blocking_head and not _BLOCKING_HEAD_RE.search(section):
if blocking_head.lower() not in section.lower():
item_reasons.append(
f"PR #{pr_number} skip missing blocking review head SHA"
)
if head_changed is False and not _HEAD_CHANGED_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing head-changed-since-blocker proof"
)
if not _BLOCKER_REASON_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing reason-it-remains-blocked"
)
if not _LIVE_BLOCKER_PROOF_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing live blocker proof for this session"
)
proven = not item_reasons
assessments.append({
"pr_number": pr_number,
"proven": proven,
"classification": (
"BLOCKER_STATUS_UNVERIFIED"
if verified is False
else "BLOCKED_SKIPPED"
),
"reasons": item_reasons,
})
reasons.extend(item_reasons)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"downgraded": False,
"assessments": assessments,
"reasons": reasons,
"safe_next_action": (
"fetch live review feedback and document blocker proof for each skipped PR, "
"or classify BLOCKER_STATUS_UNVERIFIED"
if reasons
else "proceed"
),
}
+213
View File
@@ -0,0 +1,213 @@
"""Reviewer local-fallback detection for normal PR review workflows (#324).
Normal reviewer runs must use MCP tools. Reading profile secret files or
running local Gitea helper scripts while MCP is available is a fail-closed
violation. Explicit recovery mode may use local fallback only with full proof.
"""
from __future__ import annotations
import re
from typing import Any
LOCAL_GITEA_SCRIPT_NAMES = (
"list_prs.py",
"view_pr.py",
"create_pr.py",
"merge_pr.py",
"edit_pr.py",
"list_issues.py",
"delete_branch.py",
"create_issue.py",
"close_issue.py",
"review_pr.py",
"mark_issue.py",
"mirror_refs.sh",
)
PROFILE_SECRET_MARKERS = (
"profiles.json",
".config/gitea-tools/profiles",
"gitea_auth.py",
"gitea_config.py",
"keychain",
"credential fill",
"token store",
)
_RECOVERY_MODE_RE = re.compile(
r"\b(?:recovery mode|explicit recovery|mcp unavailable|mcp not available|"
r"mcp tools unavailable|no mcp path)\b",
re.IGNORECASE,
)
_FALLBACK_CLASSIFICATION_RE = re.compile(
r"\b(?:local fallback|fallback mode|used local gitea|ran local script)\b",
re.IGNORECASE,
)
_MCP_TOOL_USE_RE = re.compile(
r"\b(?:gitea_list_prs|gitea_view_pr|gitea_review_pr|gitea_merge_pr|"
r"gitea_resolve_task_capability|gitea_whoami|mcp tool)\b",
re.IGNORECASE,
)
_LOCAL_SCRIPT_RE = re.compile(
r"(?:^|[\s\"'`/])(?:" + "|".join(re.escape(name) for name in LOCAL_GITEA_SCRIPT_NAMES) + r")",
re.IGNORECASE,
)
_PROFILE_ACCESS_RE = re.compile(
r"(?:read|open|inspect|cat|view|access(?:ed)?|loaded?)\s+(?:file\s+)?[`'\"]?"
r"[^`'\"]*profiles\.json|profiles\.json[`'\"]?\s+(?:read|opened|inspected|accessed)|"
r"~/?\.config/gitea-tools/profiles",
re.IGNORECASE,
)
_LOCAL_ENV_SCRIPT_RE = re.compile(
r"GITEA_MCP_(?:CONFIG|PROFILE)=.*\b(?:python\s+)?(?:list_prs|view_pr|create_pr|merge_pr)\.py",
re.IGNORECASE,
)
_RECOVERY_PROOF_FIELDS = (
("identity proof", ("identity proof", "exact identity proof")),
("profile proof", ("profile proof", "exact profile proof")),
("repo proof", ("repo proof", "exact repo proof")),
("capability proof", ("capability proof", "exact capability proof")),
("mcp unavailable reason", (
"why mcp was unavailable",
"mcp unavailable",
"mcp unavailability",
)),
)
def _collect_observed_text(
report_text: str,
action_log: list[dict] | None,
) -> str:
chunks = [report_text or ""]
for entry in action_log or []:
for key in ("command", "action", "detail", "path", "script"):
value = entry.get(key)
if value:
chunks.append(str(value))
return "\n".join(chunks)
def _detect_profile_secret_access(text: str) -> list[str]:
reasons = []
lower = text.lower()
if _PROFILE_ACCESS_RE.search(text):
reasons.append("report or action log shows profiles.json/profile secret access")
for marker in PROFILE_SECRET_MARKERS:
if marker == "profiles.json":
continue
if marker in lower and "do not" not in lower and "must not" not in lower:
if any(verb in lower for verb in ("read ", "open ", "inspect ", "cat ", "loaded ")):
reasons.append(f"profile secret surface '{marker}' accessed during review")
return reasons
def _detect_local_script_use(text: str) -> list[str]:
reasons = []
match = _LOCAL_SCRIPT_RE.search(text)
if match:
reasons.append(
f"local Gitea helper script invoked ({match.group(0).strip()})"
)
if _LOCAL_ENV_SCRIPT_RE.search(text):
reasons.append(
"local Gitea script run with GITEA_MCP_CONFIG/GITEA_MCP_PROFILE env overrides"
)
return reasons
def _recovery_proof_missing(text: str) -> list[str]:
lower = text.lower()
missing = []
for label, aliases in _RECOVERY_PROOF_FIELDS:
if not any(alias in lower for alias in aliases):
missing.append(label)
if not _FALLBACK_CLASSIFICATION_RE.search(text):
missing.append("fallback classification")
if not _RECOVERY_MODE_RE.search(text):
missing.append("recovery mode declaration")
return missing
def assess_reviewer_fallback_report(
report_text: str,
*,
action_log: list[dict] | None = None,
recovery_mode: bool = False,
mcp_available: bool = True,
mcp_tools_used: bool | None = None,
) -> dict[str, Any]:
"""Fail closed when normal reviewer workflows use local Gitea fallbacks (#324)."""
text = _collect_observed_text(report_text, action_log)
lower = (report_text or "").lower()
profile_violations = _detect_profile_secret_access(text)
script_violations = _detect_local_script_use(text)
violations = profile_violations + script_violations
if mcp_tools_used is None:
mcp_tools_used = bool(_MCP_TOOL_USE_RE.search(report_text or ""))
elif mcp_tools_used is False and _MCP_TOOL_USE_RE.search(report_text or ""):
mcp_tools_used = True
reasons: list[str] = []
recovery_declared = recovery_mode or bool(_RECOVERY_MODE_RE.search(report_text or ""))
if violations and mcp_available and not recovery_declared:
reasons.extend(violations)
if mcp_tools_used:
reasons.append(
"MCP tools were available and used; local fallback/profile access is forbidden"
)
else:
reasons.append(
"MCP path is available; normal review must not use local Gitea fallbacks"
)
if violations and recovery_declared:
missing = _recovery_proof_missing(report_text or "")
if missing:
reasons.append(
"recovery-mode fallback missing proof fields: "
+ ", ".join(missing)
)
if (
not violations
and recovery_declared
and _FALLBACK_CLASSIFICATION_RE.search(report_text or "")
and not recovery_mode
):
missing = _recovery_proof_missing(report_text or "")
if missing:
reasons.append(
"report claims local fallback but recovery proof is incomplete: "
+ ", ".join(missing)
)
proven = not reasons
blocked = bool(reasons) and not recovery_declared or any(
"recovery-mode fallback missing" in r or "recovery proof is incomplete" in r
for r in reasons
)
return {
"proven": proven,
"block": blocked or (bool(reasons) and mcp_available and not recovery_declared),
"downgraded": bool(reasons) and not blocked,
"recovery_mode": recovery_declared,
"mcp_available": mcp_available,
"mcp_tools_used": mcp_tools_used,
"profile_violations": profile_violations,
"script_violations": script_violations,
"reasons": reasons,
"safe_next_action": (
"use MCP reviewer tools only; do not read profiles.json or run local Gitea scripts"
if reasons and not recovery_declared
else (
"complete recovery-mode fallback proof before claiming local fallback"
if reasons
else "proceed with MCP tools"
)
),
}
+159
View File
@@ -0,0 +1,159 @@
"""Non-mergeable PR skip conflict-proof verifier for reviewer reports (#322)."""
from __future__ import annotations
import re
from typing import Any
_FULL_SHA = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_SHORT_SHA = re.compile(r"\b[0-9a-f]{7,39}\b", re.IGNORECASE)
_PR_NUMBER_RE = re.compile(r"(?:\bPR\s*#?|#)(\d+)\b", re.IGNORECASE)
_MERGEABILITY_FALSE_RE = re.compile(
r"(?:mergeable\s*:\s*false|not mergeable|non[- ]mergeable|mergeability\s*:\s*false|"
r"mergeability result\s*:\s*false)",
re.IGNORECASE,
)
_CONFLICT_PROOF_RE = re.compile(
r"(?:merge-tree|git merge-tree|gitea_view_pr|gitea_check_pr_eligibility|"
r"conflict proof|mergeability tool|merge simulation|conflicting files?)",
re.IGNORECASE,
)
_CONFLICTING_FILES_RE = re.compile(
r"(?:conflicting files?|conflict files?)\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_HEAD_CHANGED_RE = re.compile(
r"(?:head (?:changed|unchanged)|head sha (?:changed|unchanged)|"
r"head changed since|head unchanged since)",
re.IGNORECASE,
)
_MERGEABILITY_UNVERIFIED_RE = re.compile(
r"\bMERGEABILITY_UNVERIFIED\b",
)
def _pr_section(text: str, pr_number: int) -> str:
"""Return report lines likely describing one skipped PR."""
lines = (text or "").splitlines()
chunks: list[str] = []
capture = False
token = f"#{pr_number}"
for line in lines:
lower = line.lower()
if token in lower or f"pr {pr_number}" in lower or f"pr#{pr_number}" in lower.replace(" ", ""):
capture = True
chunks.append(line)
continue
if capture:
if _PR_NUMBER_RE.search(line) and token not in line.lower():
break
if line.strip() == "" and len(chunks) > 3:
break
chunks.append(line)
if chunks:
return "\n".join(chunks)
return text or ""
def _has_head_sha(text: str, head_sha: str | None) -> bool:
if not head_sha:
return bool(_FULL_SHA.search(text) or _SHORT_SHA.search(text))
head = head_sha.strip().lower()
if head in text.lower():
return True
if len(head) >= 7 and head[:7] in text.lower():
return True
return bool(_FULL_SHA.search(text))
def assess_non_mergeable_skip_proof(
report_text: str,
*,
skipped_prs: list[dict] | None = None,
) -> dict[str, Any]:
"""Validate skipped non-mergeable PR documentation in reviewer reports (#322)."""
text = report_text or ""
reasons: list[str] = []
assessments: list[dict[str, Any]] = []
for entry in skipped_prs or []:
pr_number = entry.get("pr_number")
if pr_number is None:
reasons.append("skipped PR entry missing pr_number")
continue
pr_number = int(pr_number)
section = _pr_section(text, pr_number)
mergeable = entry.get("mergeable")
verified = entry.get("mergeability_verified")
classification = (entry.get("classification") or "").strip().upper()
head_sha = (entry.get("head_sha") or "").strip() or None
item_reasons: list[str] = []
if f"#{pr_number}" not in text.lower() and f"pr {pr_number}" not in text.lower():
item_reasons.append(f"skipped PR #{pr_number} not documented in final report")
if mergeable is False or verified is False:
if not _MERGEABILITY_UNVERIFIED_RE.search(section) and verified is False:
if "MERGEABILITY_UNVERIFIED" not in classification:
item_reasons.append(
f"PR #{pr_number} conflict proof unavailable; report must classify "
"MERGEABILITY_UNVERIFIED"
)
elif verified is not False:
if not _MERGEABILITY_FALSE_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing mergeability:false proof"
)
if not _has_head_sha(section, head_sha):
item_reasons.append(
f"PR #{pr_number} skip missing current head SHA"
)
if not _CONFLICT_PROOF_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing conflict proof command/tool"
)
if entry.get("head_changed_since_prior_blocker") is not None:
if not _HEAD_CHANGED_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing head-changed-since-blocker proof"
)
if (
entry.get("conflicting_files")
and not _CONFLICTING_FILES_RE.search(section)
and not any(
path.lower() in section.lower()
for path in (entry.get("conflicting_files") or [])
)
):
item_reasons.append(
f"PR #{pr_number} has conflicting files in session proof but "
"report omits file-level conflict proof"
)
proven = not item_reasons
assessments.append({
"pr_number": pr_number,
"proven": proven,
"classification": classification or (
"MERGEABILITY_UNVERIFIED" if verified is False else "NON_MERGEABLE_SKIPPED"
),
"reasons": item_reasons,
})
reasons.extend(item_reasons)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"downgraded": False,
"assessments": assessments,
"reasons": reasons,
"safe_next_action": (
"document each skipped non-mergeable PR with head SHA, mergeability result, "
"conflict proof command, conflicting files, and head-change status"
if reasons
else "proceed"
),
}
+210
View File
@@ -0,0 +1,210 @@
"""Fail-closed reviewer worktree and local-git safety proofs (#233).
Reviewer sessions must never stash, reset, or otherwise manipulate unrelated
local changes from another session. When the active worktree has dirty tracked
files outside the PR scope, the workflow must stop or switch to a disposable
scratch worktree (``scripts/worktree-review``).
Git command policy (#243): reviewers use an allowlist, not a blocklist.
Any ``git`` invocation that does not match ``_READONLY_REVIEWER_GIT`` is
forbidden — including ``checkout HEAD --``, ``checkout .``, ``switch``,
and uncommon ``stash`` subcommands that older blocklists missed.
"""
from __future__ import annotations
import re
import shlex
# Read-only git operations reviewers may use for validation (#243 allowlist).
_READONLY_REVIEWER_GIT = re.compile(
r"\bgit\b(?:\s+(?:-C\s+\S+\s+)?)?"
r"(?:fetch|status|diff|log|show|rev-parse|branch(?:\s+--show-current)?|"
r"worktree\s+list|worktree\s+add)",
re.IGNORECASE,
)
_GIT_INVOCATION = re.compile(r"\bgit\b", re.IGNORECASE)
def parse_dirty_tracked_files(porcelain: str) -> list[str]:
"""Return tracked paths with local modifications from ``git status --porcelain``.
Untracked entries (``??``) are ignored — they do not block reviewer work
when a scratch worktree is used, and authors may have unrelated untracked
files without implying reviewer interference.
"""
paths: list[str] = []
for line in (porcelain or "").splitlines():
if not line or len(line) < 4:
continue
if line.startswith("??"):
continue
path = line[3:].strip()
if " -> " in path:
path = path.split(" -> ", 1)[1].strip()
if path:
paths.append(path)
return paths
def files_outside_pr_scope(
dirty_files: list[str] | None,
pr_scope_files: list[str] | None,
) -> list[str]:
"""Dirty tracked files not explained by the PR diff file set."""
dirty = [p for p in (dirty_files or []) if p]
scope = {p for p in (pr_scope_files or []) if p}
if not dirty:
return []
if not scope:
return list(dirty)
return [path for path in dirty if path not in scope]
def _is_git_command(command: str) -> bool:
return bool(_GIT_INVOCATION.search((command or "").strip()))
def is_readonly_reviewer_git_command(command: str) -> bool:
"""True when the command is an explicitly allowed read-only git operation."""
text = (command or "").strip()
if not text or not _is_git_command(text):
return False
return bool(_READONLY_REVIEWER_GIT.search(text))
def is_forbidden_reviewer_git_command(command: str) -> bool:
"""True when a git command is not on the reviewer readonly allowlist."""
text = (command or "").strip()
if not text or not _is_git_command(text):
return False
return not is_readonly_reviewer_git_command(text)
def assess_reviewer_git_command_log(commands: list[str] | None) -> dict:
"""Fail closed when reviewer shell history includes forbidden git mutations."""
forbidden = [
cmd for cmd in (commands or []) if is_forbidden_reviewer_git_command(cmd)
]
if forbidden:
return {
"proven": False,
"block": True,
"forbidden_commands": forbidden,
"reasons": [
"reviewer workflow executed forbidden local git mutation: "
f"{cmd!r}"
for cmd in forbidden
],
"safe_next_action": (
"stop; report worktree interference; do not stash/reset/checkout "
"unrelated files — use scripts/worktree-review instead"
),
}
return {
"proven": True,
"block": False,
"forbidden_commands": [],
"reasons": [],
"safe_next_action": "proceed",
}
def assess_reviewer_worktree_proof(proof: dict | None) -> dict:
"""Evaluate reviewer worktree safety before checkout/diff/validation/review.
*proof* keys:
- ``worktree_path`` (required)
- ``porcelain_status`` or ``dirty_files``
- ``pr_scope_files`` (paths in the PR diff)
- ``scratch_used`` (bool)
- ``scratch_path`` (when scratch_used)
- ``git_commands`` (shell commands executed this session)
- ``unrelated_mutations_claimed`` (bool) — stash/reset/drop reported
"""
proof = dict(proof or {})
reasons: list[str] = []
worktree_path = (proof.get("worktree_path") or "").strip()
if not worktree_path:
reasons.append("reviewer worktree path not reported; fail closed")
if proof.get("dirty_files") is not None:
dirty_files = list(proof.get("dirty_files") or [])
else:
dirty_files = parse_dirty_tracked_files(proof.get("porcelain_status") or "")
pr_scope = list(proof.get("pr_scope_files") or [])
unrelated = files_outside_pr_scope(dirty_files, pr_scope)
scratch_used = bool(proof.get("scratch_used"))
scratch_path = (proof.get("scratch_path") or "").strip()
is_dirty = bool(dirty_files)
unrelated_dirty = bool(unrelated)
if unrelated_dirty and not scratch_used:
reasons.append(
"worktree has dirty tracked files outside PR scope "
f"({', '.join(unrelated)}); stop or use a scratch worktree"
)
if scratch_used and not scratch_path:
reasons.append(
"scratch worktree was used but scratch_path was not reported"
)
if proof.get("unrelated_mutations_claimed"):
reasons.append(
"reviewer reported stash/reset/checkout cleanup of unrelated "
"local changes; this is forbidden"
)
command_assessment = assess_reviewer_git_command_log(
list(proof.get("git_commands") or [])
)
if command_assessment["block"]:
reasons.extend(command_assessment["reasons"])
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"worktree_path": worktree_path or None,
"is_dirty": is_dirty,
"dirty_files": dirty_files,
"unrelated_dirty_files": unrelated,
"scratch_used": scratch_used,
"scratch_path": scratch_path or None,
"unrelated_mutations_avoided": not bool(
proof.get("unrelated_mutations_claimed")
or command_assessment.get("forbidden_commands")
),
"safe_next_action": (
"proceed"
if proven
else command_assessment.get("safe_next_action")
or "stop; use scripts/worktree-review or report dirty worktree"
),
"forbidden_commands": command_assessment.get("forbidden_commands", []),
}
def assess_author_worktree_continuity(proof: dict | None) -> dict:
"""Authors may keep dirty feature worktrees; reviewers may not manipulate them.
This helper only proves the task role is author when dirty unrelated files
exist — it does not grant reviewers an exception.
"""
proof = dict(proof or {})
role = (proof.get("task_role") or "").strip().lower()
dirty_files = list(proof.get("dirty_files") or [])
if role == "author" and dirty_files:
return {
"allowed": True,
"reasons": [
"author task may continue with dirty tracked files in its own "
"worktree; reviewer interference rules do not apply"
],
}
if role == "reviewer" and dirty_files:
return assess_reviewer_worktree_proof(proof)
return {"allowed": True, "reasons": []}
+163 -48
View File
@@ -14,6 +14,45 @@ ROUTE_TO_REVIEWER = "route_to_reviewer_session"
ROUTE_AMBIGUOUS = "ambiguous_task_stop"
ROUTE_INFRA_STOP = "infra_stop"
_CONFLICT_HEAD = b"<" * 7 + b" "
_CONFLICT_TAIL = b">" * 7 + b" "
_CONFLICT_SEPARATOR = b"=" * 7
def python_bytes_have_conflict_markers(content: bytes) -> bool:
"""Return True when *content* contains git merge-conflict marker lines."""
for line in content.splitlines():
stripped = line.rstrip(b"\r\n")
if stripped.startswith(_CONFLICT_HEAD):
return True
if stripped.startswith(_CONFLICT_TAIL):
return True
if stripped == _CONFLICT_SEPARATOR:
return True
return False
def skip_python_scan_walk_root(project_root: str, walk_root: str) -> bool:
"""Skip venv/git/cache and sibling worktrees under orchestration checkout.
When *project_root* is itself a worktree inside ``branches/``, still scan
that tree — do not treat the ``branches`` path segment as a skip signal.
"""
rel = os.path.relpath(walk_root, project_root)
if rel == ".":
return False
head = rel.split(os.sep, 1)[0]
if head in ("venv", ".git", ".pytest_cache"):
return True
if head == "branches":
nested = os.path.join(project_root, "branches")
if os.path.isdir(nested) and (
walk_root == nested or walk_root.startswith(nested + os.sep)
):
return True
return False
REVIEWER_TASKS = frozenset({
"review_pr",
"merge_pr",
@@ -95,22 +134,28 @@ def route_task_session(
_record_route(result)
return result
if required_role == "reviewer" and check_mid_merge():
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_INFRA_STOP,
"downstream_allowed": False,
"reasons": [
"infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source.",
"Please resolve all conflicts manually, finish/abort the merge, restart the MCP server, and retry."
],
"message": "infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source.",
}
_record_route(result)
return result
if required_role == "reviewer":
infra = assess_infra_stop()
if infra["infra_stop"]:
detail = "; ".join(infra.get("infra_stop_reasons") or [])
message = (
"infra_stop: Unresolved merge conflict or mid-merge state detected "
f"in MCP runtime source ({detail}). Please resolve all conflicts "
"manually, finish/abort the merge, and retry."
)
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_INFRA_STOP,
"downstream_allowed": False,
"reasons": [message],
"message": message,
"infra_stop_assessment": infra,
}
_record_route(result)
return result
if allowed_in_current_session:
result = {
@@ -194,11 +239,56 @@ def _record_route(result: dict):
_session_last_route = dict(result)
def sync_route_from_capability(capability: dict) -> None:
"""Align sticky route state with operation-scoped capability resolution (#228)."""
capability = capability or {}
task = (capability.get("requested_task") or "").strip()
required_role = capability.get("required_role_kind")
if not task or not required_role:
return
if capability.get("allowed_in_current_session"):
_record_route({
"task_type": task,
"required_role": required_role,
"active_role": required_role,
"active_profile": capability.get("active_profile"),
"route_result": ROUTE_ALLOWED,
"downstream_allowed": True,
"reasons": [],
"message": (
f"Operation-scoped task '{task}' resolved for current session; "
"proceed."
),
})
return
if required_role == "reviewer" and capability.get("stop_required"):
_record_route({
"task_type": task,
"required_role": required_role,
"active_role": capability.get("required_role_kind"),
"active_profile": capability.get("active_profile"),
"route_result": ROUTE_WRONG_ROLE,
"downstream_allowed": False,
"reasons": [WRONG_ROLE_REVIEWER_MSG],
"message": WRONG_ROLE_REVIEWER_MSG,
})
def check_author_mutation_after_reviewer_stop(mutation_task: str) -> tuple[bool, list[str]]:
"""Block author-side fallback after a reviewer wrong_role_stop (#206)."""
"""Block author-side fallback after a reviewer wrong_role_stop (#206).
An explicit operation-scoped author capability resolution for the same
*mutation_task* clears the sticky reviewer denial (#228).
"""
last = _session_last_route
if not last:
return True, []
if (
last.get("route_result") == ROUTE_ALLOWED
and last.get("task_type") == mutation_task
and last.get("required_role") == "author"
):
return True, []
if last.get("route_result") != ROUTE_WRONG_ROLE:
return True, []
if last.get("required_role") != "reviewer":
@@ -207,41 +297,66 @@ def check_author_mutation_after_reviewer_stop(mutation_task: str) -> tuple[bool,
return False, [
WRONG_ROLE_REVIEWER_MSG,
"Author-side mutations are blocked after a reviewer-task "
"wrong_role_stop unless the operator explicitly changes the "
"task and relaunches an author MCP session.",
"wrong_role_stop unless the operator explicitly resolves the "
"author task via gitea_resolve_task_capability.",
f"Attempted fallback mutation: {mutation_task}",
]
return True, []
def check_mid_merge() -> bool:
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
project_root = os.path.dirname(os.path.abspath(__file__))
git_dir = os.path.join(project_root, ".git")
if os.path.exists(git_dir):
if (os.path.exists(os.path.join(git_dir, "MERGE_HEAD"))
or os.path.exists(os.path.join(git_dir, "rebase-merge"))
or os.path.exists(os.path.join(git_dir, "rebase-apply"))):
return True
# Scan python files for conflict markers
conflict_patterns = [
b"<" * 7 + b" ",
b"=" * 7 + b"\n",
b"=" * 7 + b"\r\n",
b">" * 7 + b" "
]
for root, dirs, files in os.walk(project_root):
if any(p in root for p in ("venv", ".git", ".pytest_cache", "branches")):
def first_conflict_marker_path(project_root: str | None = None) -> str | None:
"""Return the first .py path containing a git conflict marker, or None."""
root_dir = project_root or os.path.dirname(os.path.abspath(__file__))
for root, dirs, files in os.walk(root_dir):
if skip_python_scan_walk_root(root_dir, root):
continue
for file in files:
if file.endswith(".py"):
file_path = os.path.join(root, file)
try:
with open(file_path, "rb") as f:
content = f.read()
if any(pattern in content for pattern in conflict_patterns):
return True
except Exception:
pass
return False
if not file.endswith(".py"):
continue
file_path = os.path.join(root, file)
try:
with open(file_path, "rb") as f:
if python_bytes_have_conflict_markers(f.read()):
return file_path
except OSError:
pass
return None
def _default_project_root() -> str:
override = (os.environ.get("GITEA_MCP_PROJECT_ROOT") or "").strip()
if override:
return os.path.realpath(override)
return os.path.dirname(os.path.abspath(__file__))
def assess_infra_stop(project_root: str | None = None) -> dict:
"""Recompute infra_stop from live git and source scan state (#285)."""
root_dir = os.path.realpath(project_root or _default_project_root())
reasons: list[str] = []
mid_merge = False
git_dir = os.path.join(root_dir, ".git")
if os.path.isdir(git_dir):
for marker in ("MERGE_HEAD", "rebase-merge", "rebase-apply"):
marker_path = os.path.join(git_dir, marker)
if os.path.exists(marker_path):
mid_merge = True
reasons.append(f"git state: {marker} present under {git_dir}")
conflict_file = first_conflict_marker_path(root_dir)
if conflict_file:
reasons.append(
f"conflict markers detected in {conflict_file} (project_root={root_dir})"
)
infra_stop = mid_merge or bool(conflict_file)
return {
"infra_stop": infra_stop,
"project_root": root_dir,
"conflict_file": conflict_file,
"mid_merge": mid_merge,
"infra_stop_reasons": reasons,
}
def check_mid_merge(project_root: str | None = None) -> bool:
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
return assess_infra_stop(project_root)["infra_stop"]
+129 -535
View File
@@ -1,585 +1,179 @@
---
name: llm-project-workflow
description: >-
Portable, safe operating workflow for LLMs working on any Git/forge project:
issue-first, isolated branch worktrees, no self-review/self-merge, distinct
author/reviewer profiles, cleanup after merge, and fail-closed behavior.
Use at the start of any implementation, review, or merge task on a repo.
Router skill for safe LLM project work: identify task mode, load the matching
canonical workflow file, enforce mode isolation, and emit the correct final
report schema. Use at the start of any implementation, review, merge,
reconciliation, or issue-filing task.
---
# LLM Project Workflow
# LLM Project Workflow Skill
A reusable workflow any LLM can follow to work on any repository safely. Copy
this `skills/llm-project-workflow/` directory into another project unchanged;
adapt only the forge-specific names in [Adapting to a project](#adapting-to-a-project).
This skill is a **router**. Do not perform project work from this file alone.
The core promise: **an LLM never does unsafe or untracked work.** Every change
is tracked by an issue, isolated in its own worktree, reviewed by a different
identity, and cleaned up only after a real merge.
Before any project mutation, identify the task mode and load the matching
workflow file.
## Workflow modes
| Task mode | Workflow | Final report schema |
|-----------|----------|---------------------|
| PR review / approval / merge | [`workflows/review-merge-pr.md`](workflows/review-merge-pr.md) | [`schemas/review-merge-final-report.md`](schemas/review-merge-final-report.md) |
| Reconcile already-landed open PRs | [`workflows/reconcile-landed-pr.md`](workflows/reconcile-landed-pr.md) | [`schemas/reconcile-landed-final-report.md`](schemas/reconcile-landed-final-report.md) |
| Create or update Gitea issues | [`workflows/create-issue.md`](workflows/create-issue.md) | [`schemas/create-issue-final-report.md`](schemas/create-issue-final-report.md) |
| Work on an assigned issue / author code | [`workflows/work-issue.md`](workflows/work-issue.md) | [`schemas/work-issue-final-report.md`](schemas/work-issue-final-report.md) |
## Universal rules
- Prove identity, active profile, runtime context, and **exact** capability before
mutation.
- A nearby capability does not count.
- Do not self-review or self-merge.
- Do not mix modes in one run.
- If the required workflow cannot be loaded, stop and produce a recovery handoff
only.
- Final report must use the schema for the loaded workflow.
- If a task requires a different mode, stop and produce a handoff for the
correct workflow.
## Mode isolation
A run that starts in `review-merge-pr` mode may not create process issues,
implement fixes, or edit source files.
A run that starts in `reconcile-landed-pr` mode may not approve, request
changes, merge, implement fixes, or create normal issues.
A run that starts in `create-issue` mode may not review, approve, request
changes, merge, implement fixes, create branches, commit, push, or create PRs.
A run that starts in `work-issue` mode may not review, approve, request changes,
merge, close PRs, or act as reviewer.
If the task requires a different mode, stop and produce a handoff for the
correct workflow.
---
## Definitions
- **Merged**: Gitea PR metadata says `merged=true`.
- **Landed**: Equivalent content is present on remote `master`, but PR metadata may not say merged.
- **Landed**: Equivalent content is present on remote `master`, but PR metadata
may not say merged.
- **Closed-not-merged**: PR state is closed and `merged=false`.
- **Reconciled**: A human/LLM verified whether closed-not-merged content landed, partially landed, or was lost, and repaired issue/label/tracker state.
- **Reconciled**: Verified whether closed-not-merged or already-landed content
is present on the target branch; issue/label/tracker state repaired.
## A. Issue-first rule
## Work Selection Rule for LLMs
**No repository change without a tracking issue.** This includes creating,
editing, deleting, or `chmod`-ing files; docs; scripts; commits; pushes; and PRs.
Before starting any issue or PR work, acquire or verify a work lease. Do not
begin coding, reviewing, fixing, branching, committing, pushing, commenting, or
creating a PR until you prove the target is not already being worked.
1. Before any change, confirm a tracking issue exists.
2. If none exists, create one first (title + problem + scope + acceptance).
3. Claim it (assign yourself or apply the `status:in-progress` label) and comment
that work is starting, including the planned branch name.
4. **If the issue cannot be created or claimed, stop.** Do not touch files.
Required checks:
Reading the repo, running read-only status/`git log`, and creating/claiming the
issue itself are allowed from the orchestration checkout without a prior issue.
1. List open PRs.
2. Search for PRs linked to the target issue.
3. Search local and remote branches for the issue number.
4. Search registered worktrees for the issue branch.
5. Check dirty worktrees.
6. Check active leases or recent handoffs.
7. Check whether the issue was already completed by a merged PR.
Additional issue-first rules:
If another active session owns the lease, stop with "work already claimed" or
produce a handoff.
- Do not implement code without an issue unless explicitly authorized.
- **Design-only work uses a discussion/RFC issue** — create one or comment on
the existing one. Design debates belong on the issue, where other LLMs
comment directly. Discussion-only tasks must **not** create branches or PRs;
their comments should include recommendations, risks, open questions, and a
Controller Handoff (§K; compact format unless high-risk).
- **If the repo/tracker home for the work is unclear, stop and ask for an
owner decision.** Do not create a new repository or a new tracker unless
explicitly approved by the owner.
For Gitea-Tools: `gitea_lock_issue` is the fail-closed lease gate before author
mutations; `status:in-progress` and claim comments are supporting lease signals.
## B. Isolated worktree rule
## Global LLM Worktree Rule
**Never implement or review in the main checkout.** The main checkout is for
orchestration and status only (issue creation, `git status`, creating worktrees).
The main project checkout is a stable control checkout on `master`, `main`, or
`dev`. All LLM task work must happen inside the project's `branches/` directory.
- Each issue gets its own branch worktree under an ignored `branches/` directory.
- Review work uses a **separate** review worktree, never the author's folder.
- Dirty work in one branch folder must not block starting another issue.
- No LLM may edit another issue's worktree unless explicitly assigned to it.
- Branch folders are removed only after the PR is merged/closed **and** cleanup
is explicitly part of the task.
If `cwd` is not inside `branches/`, stop before any file edit, test write,
commit, merge, rebase, or cleanup. The main checkout is orchestration-only.
Every implementation branch **must include its issue number** so it is
traceable end to end: **issue → branch → worktree folder → PR → cleanup.**
## Shell Spawn Hard-Stop Rule
Allowed implementation patterns:
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
command failure. After two consecutive spawn failures, hard-stop shell use for
the session and emit a recovery report (#258).
- `fix/issue-123-short-description`
- `feat/issue-123-short-description`
- `docs/issue-123-short-description`
- `chore/issue-123-short-description`
## Isolated worktree naming
Review-only branches:
Implementation: `(fix|feat|docs|chore)/issue-<number>-<short-description>`
- `review/pr-456-short-description`
Review: `review/pr-<number>-<short-description>`
Use a filesystem-safe folder under `branches/` by replacing slashes with
hyphens, for example `branches/fix-issue-123-short-description`.
## Subagent Tool-Budget Guardrails
`scripts/worktree-start` **enforces** this: it rejects an implementation branch
that does not match `(fix|feat|docs|chore)/issue-<number>-…` (or a
`review/pr-<number>-…` branch), unless `--allow-unlinked` is passed. Traceability
is maintained by:
General-purpose subagents on **single-step MCP tasks** (for example
`gitea_commit_files`) must not expand into 100+ tool-call retry spirals with
WebFetch/Playwright/manual-encoding fallbacks (issue #259).
- the branch name (contains the issue number),
- a claim comment on the issue, e.g.
`Claimed. Branch: fix/issue-123-short-description. Worktree: branches/fix-issue-123-short-description.`,
- the PR body — `Closes #123` or `Fixes #123` when the PR should close the issue
(do NOT use `Implements #123` or `Refs #123` to close, as Gitea will not auto-close),
- cleanup after merge — remove the remote branch, local branch, and the issue
worktree folder, and drop `status:in-progress`.
Default budgets (stop when exceeded):
For projects using `Gitea-Tools` helpers:
- **Single-step MCP mutation** (`commit_files`, `create_pr`, `lock_issue`):
15 tool calls, 5 minutes wall time.
- **Review / merge queue inspection**: 40 tool calls, 15 minutes.
- **Non-mutating exploration**: 60 tool calls, 20 minutes.
```bash
scripts/worktree-start fix/issue-123-example # → branches/fix-issue-123-example
scripts/worktree-review fix/issue-123-example # → branches/review-fix-issue-123-example (detached)
scripts/worktree-clean --delete-branch fix/issue-123-example
```
Rules:
Manual equivalent:
1. When the main session has `gitea.repo.commit`, call `gitea_commit_files`
directly — do not delegate commit to a subagent (#260).
2. After shell spawn failure (#258), attempt the native MCP tool once before
any fallback; shell unavailability never authorizes WebFetch/Playwright/
manual base64.
3. Never resume a failed subagent into a larger retry loop or spawn a second
subagent for the same deterministic step — stop and report.
4. When `gitea_commit_files` is available, forbid WebFetch, Playwright,
manual encoding, and ad-hoc `_encode_*` / `_emit_*` helpers in the repo.
```bash
git fetch <remote> --prune
git worktree add -b fix/issue-123-example branches/fix-issue-123-example <remote>/master
cd branches/fix-issue-123-example
```
Worktree folder: branch with `/` replaced by `-` under `branches/`.
`venv/` and similar are not copied into new worktrees — run checks with a known
interpreter path, or create a venv inside the branch folder.
Helpers: `scripts/worktree-start`, `scripts/worktree-review`,
`scripts/worktree-clean`.
## C. Identity and profile safety
## Identity and profile safety
- Use canonical execution profiles where available; the profile is the role, not the LLM. A task selects a profile; a profile is not permanently assigned.
- **Author and reviewer identities must be distinct.**
- Never place raw tokens/passwords in an LLM/MCP client config. Reference secrets by keychain id or environment variable name only. Prefer a single canonical config file selected by two env vars, e.g.:
- `GITEA_MCP_CONFIG` — path to the canonical profiles file
- `GITEA_MCP_PROFILE` — the profile to activate
- **Dual-Profile MCP Launcher Pattern (Recommended):** To avoid relaunch bottlenecks and PR-author deadlocks, register multiple instances of the same MCP server in the client's configuration simultaneously (e.g., `gitea-author` and `gitea-reviewer`), each pointing to its respective `GITEA_MCP_PROFILE`.
- Tool calls become namespace-scoped: `mcp__gitea-author__*` and `mcp__gitea-reviewer__*`.
- **Trust Model:** Separate tokens remain separate. Profile gates enforce allowed operations, `whoami` is still checked, and self-review/self-merge prevention remains mandatory. This pattern is for convenience and does not bypass security gates.
- **Deadlock Warning:** Reviewer/merge identities must not be used to create PRs, as this makes the reviewer the PR author in Gitea and blocks independent review. PRs should normally be created by the author/work identity, keeping the reviewer identity available for reviews.
- **Fallback:** If a dual-server launcher is not available in the client, relaunch or restart the client with the correct profile environment variable before claiming work.
- **If the authenticated user equals the PR author, stop** — no self-review, no self-merge.
- Author and reviewer identities must be distinct.
- Never place raw tokens in LLM/MCP config.
- Use `gitea_whoami` and `gitea_resolve_task_capability` before mutating.
## D. Branch naming
```text
fix/issue-123-short-description
feat/issue-123-short-description
docs/issue-123-short-description
review/pr-456-scope-check
```
Worktree folder = branch with `/` replaced by `-`
(`branches/fix-issue-123-short-description`).
## E. Start-work workflow
1. Verify the orchestration checkout (right repo, clean tree).
2. Fetch/prune: `git fetch <remote> --prune`.
3. Confirm local `master` equals remote `master` (`git rev-list --left-right --count <remote>/master...master``0 0`).
4. Create/claim the issue (§A).
5. Create the isolated worktree (§B) from latest remote `master`.
6. Implement the narrow scope only — no unrelated refactors or formatting churn.
7. Add/update focused tests when behavior changes.
8. Run the checks (tests, compile/lint, `git diff --check`, secret scan).
Record the branch name and `HEAD` SHA at validation time — the drift
check in step 9 compares against exactly this state.
9. **Branch proof before commit (#177):** prove and state, immediately
before staging/committing (`author_proofs.verify_branch_for_commit`,
`author_proofs.detect_branch_drift`):
- current branch (`git branch --show-current`) equals the intended
feature branch from the issue claim
- current branch is not `master`, `main`, `develop`, `development`, or
`dev`
- branch and `HEAD` have not changed since validation (step 8) — in a
shared checkout another session may switch branches mid-session;
treat that as expected and **stop before committing** when detected
If any check fails, stop and reconcile; do not commit.
10. Commit with an issue-linked message.
11. **Branch proof before push (#177):** prove that the local branch, the
push target branch, and the intended issue branch all match, and that
none of them is a protected branch
(`author_proofs.verify_push_target`). If a commit accidentally landed
on a protected branch, do **not** push: report the accident and the
exact repair steps (`author_proofs.assess_protected_branch_commit`) —
never silently continue after a repair.
12. Push the branch.
13. Open a PR to `master`. The final report must include the branch proofs
from steps 9 and 11 (`author_proofs.build_commit_push_report`).
14. **If you are the author, stop before review/merge.**
15. **Normal issue work must not directly push to `master`.** PR content should be merged through the forge PR merge mechanism.
16. Direct push to `master` is allowed only as a documented recovery exception. If used, the final report must include:
- why the PR merge path could not be used
- exact commits pushed
- PR metadata state
- issue labels/state repaired
- whether the PR is closed-not-merged
## F. Review workflow
1. Use a separate review worktree (`scripts/worktree-review <branch>`), detached.
2. Verify your authenticated identity.
3. Verify the PR author — **you must not be the author.** Self-review
contamination must be *evidence-backed* (#173): state the authenticated
reviewer identity, the PR author identity, whether this session
authored/touched the PR branch, and the evidence source for any
"same-session author" claim. If the evidence is missing, report the
status as **unknown** — never declare contamination by assumption — and
choose another PR or stop (`review_proofs.assess_self_review_contamination`).
4. Verify the worktree is clean.
5. **Checkout proof (#173):** before reviewing or validating, prove and
state: the selected PR head SHA from Gitea (pinned), the local checkout
SHA (`git rev-parse HEAD`), that `HEAD ==` the pinned PR head SHA, and
that the diff base is the PR base branch. If `HEAD` does not match the
pinned head, **stop before review/merge**
(`review_proofs.verify_pinned_head_checkout`).
6. **Inventory proof (#173 + repo disambiguation hardening):** a blind queue
review must prove listing completeness before claiming "only PRs found".
Use repo-name disambiguation:
- "Gitea-Tools" / "gitea tool" / "MCP Gitea tool" / "gitea MCP tool" /
"gitea-tools repo" resolve **only** to `Scaled-Tech-Consulting/Gitea-Tools`.
- "mcp-control-plane" resolves only to `Scaled-Tech-Consulting/mcp-control-plane`.
- Ambiguous ("open PRs", no explicit repo, "MCP Gitea tooling") → inventory
**both** configured repos.
Report must state exactly which repo(s) were checked. If only one checked:
"Only <repo> was checked. Other configured repos were not checked. This is
not a complete queue inventory." Never let a single-repo zero hide PRs in
the other.
Both configured repos must be reported with state filter, pagination proof,
and open-PR count (`review_proofs.assess_inventory_completeness` and
`resolve_repos_from_user_reference`).
7. **Role-boundary proof (#175):** a reviewer queue task must not silently
become author implementation. If no eligible PR exists, stop with the
queue report. Do not claim issues, create branches, commit, push, or open
PRs unless the operator explicitly retasks the run as author work. Mixed
reviewer+author namespace use must be reported with a justification, and
scratch-only notes are not durable evidence unless posted or committed
intentionally (`review_proofs.assess_role_boundary`).
8. Inspect the full diff; confirm scope matches the linked issue; flag unrelated files.
9. Run the tests. Validation reporting must include the exact command and
exact results: pass/fail, counts of tests passed/skipped/failed, any
ignored paths and why they are safe to ignore, and whether the command
differs from the repository's canonical validation command. Only claim a
validation result after the command has completed and its output has
been read (`review_proofs.assess_validation_report`).
During validation, review work is **read-only**: use
`gitea_dry_run_pr_review` to prove submission mechanics — never post live
APPROVE, REQUEST_CHANGES, or review comments to probe tool paths. After
validation completes, call `gitea_mark_final_review_decision`, then submit
exactly one live review via
`gitea_submit_pr_review(..., final_review_decision_ready=True)`.
Final reports must list exactly one review mutation
(`review_proofs.assess_review_mutation_final_report`) unless an
operator-approved correction flow was invoked and explained.
10. **Do not merge if checks fail. Do not merge if the reviewer is the author.**
11. **#179 A-bar proofs** (all fail closed when missing —
`review_proofs.assess_capability_evidence`, `assess_sweep_evidence`,
`assess_live_state_recheck`, `assess_role_boundary`):
- Capability claims must cite exact `gitea_resolve_task_capability`
output (or runtime context); a bare "capability checks passed" is
downgraded.
- The secret/provenance sweep must state the exact command/script/
pattern/named method and the scope scanned.
- Immediately before submitting a review verdict (and again before any
merge), re-read live PR state and prove: still open, live head ==
pinned head, base unchanged, no unresolved blocking review state.
- Reviewer runs stay in the reviewer namespace; any author-namespace
call requires an explicit justification in the report.
12. The final report must distinguish (`review_proofs.build_final_report`):
identity eligible; PR author different from reviewer; session
contamination absent (with evidence); validation performed on the pinned
head; capability evidence; sweep verdict; live-state recheck; role
boundary; merge performed; issue status verified. If any proof is
missing, stop or downgrade the result instead of merging confidently.
## G. Merge / cleanup workflow
Only an eligible (non-author) reviewer merges. Before merging: always verify
the authenticated identity **and** the PR author; cite exact capability
evidence for merge_pr (#179); respect runtime profile gates; run independent
validation (do not trust the author's reported results); perform the **final
live-state recheck** (#179 — PR still open, live head == pinned head, base
unchanged, no unresolved blocking review state) immediately before the merge
mutation; and merge with a **pinned head SHA** and, where supported, the
**expected changed-file set**, so a moved head or widened diff refuses the
merge. After a real merge:
1. Confirm remote `master` actually contains the merge commit or expected squashed changes via post-merge file-presence verification (A PR is not done just because `master` moved or is marked "closed". Verify that expected files added/modified in the PR are actually present on `master` using `git pull`, `git log --oneline -- <file>`, or `git merge-base --is-ancestor`; linked issues are closed; `status:in-progress` is removed).
2. Close/release the issue.
3. Whenever an issue is closed, check for `status:in-progress`: remove it, or report why it could not be removed.
4. Do not delete the remote source branch until: PR `merged=true`, or reconciliation confirms content is safely landed, or the issue owner explicitly abandons the work.
5. Remove the local branch.
6. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`). Branches/worktrees are cleaned only after the above is verified.
7. Fetch/prune.
8. Confirm the main checkout is clean and current (`0 0` vs remote).
9. Final merge/reconciliation reports must include: PR metadata (state, merged flag, merge commit/hash), Git content (remote master hash, expected content present or not), and the exact post-merge verification method used & results.
Never run cleanup before the merge is confirmed on remote `master`.
## H. Fail-closed cases
**Stop and report — take no mutating action — if:**
- No issue exists and one cannot be created.
- Worktree state is unclear or unexpected.
- Branch/PR state conflicts with the prompt (e.g. prompt says "merged" but it is not).
- A PR is closed but not merged (closed with `merged=false`). In this case:
- stop normal review/merge
- do not delete branches/worktrees
- do not start dependent work
- run reconciliation
- Local `master` is ahead of remote unexpectedly.
- The authenticated user is the PR author (for review/merge).
- Secrets/tokens appear in the diff.
- Tests fail.
- A cleanup step would delete unmerged work.
When in doubt, stop and surface the discrepancy; do not guess or work around a gate.
## I. Recovery patterns
- **Dirty worktree from another issue:** do not touch it. Start your issue in its
own new worktree; unrelated dirty work must not block you.
- **Local `master` ahead of remote unexpectedly:** do not push `master`. Confirm
the commits are preserved on a feature branch (local + remote) first, then
`git reset --hard <remote>/master` to realign. Never discard commits that are
not safely pushed elsewhere.
- **PR closed but not merged (`merged=false`):** do not merge. Run reconciliation: compare PR content to remote `master` and decide:
- **fully landed:** comment that content is present on `master`, remove `status:in-progress`, keep/close issue as appropriate, clean up only after content equivalence is confirmed.
- **partially landed:** do not clean up, reopen issue if needed, create corrective issue/PR for missing pieces.
- **not landed:** reopen issue if needed, reopen PR or create replacement PR, do not clean up source branch/worktree.
- **Branch deleted before merge:** if the commits still exist locally (a branch or
reflog), re-push them and reopen the PR; otherwise recover via
`git fsck --lost-found`. Preserve first, then proceed.
- **Unauthorized/untracked file created:** do not commit it. Leave pre-existing
untracked artifacts (e.g. editor/agent dirs, reports) alone; stage only the
files your issue names (`git add <files>`, never blind `git add -A`).
- **Preserve commits before a reset:** confirm the target commits are reachable
from a branch that is pushed to the remote, then reset. Verify with
`git branch --contains <sha>` and `git log <remote>/<branch>`.
## J. Prompt snippets
Ready-to-copy templates live in [`templates/`](templates/):
- [`start-issue.md`](templates/start-issue.md) — start a new issue.
- [`review-pr.md`](templates/review-pr.md) — review a PR.
- [`merge-pr.md`](templates/merge-pr.md) — merge a PR (eligible reviewer only).
- [`recover-bad-state.md`](templates/recover-bad-state.md) — recover from bad state.
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md) — reconcile a closed-not-merged PR.
- [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge.
- [`release-tag.md`](templates/release-tag.md) — create a release tag.
## K. Controller Handoff (required, every task)
Every LLM task **must end with a `Controller Handoff`** (exact title) — whether the
task was implementation, review, merge, issue triage, documentation,
discussion-only, or blocked planning. It lets a controller LLM understand the
current state immediately, without rereading the conversation.
The section title must be exactly "Controller Handoff" (or "Controller Handoff Summary" for long form). Reports without it are downgraded (see review_proofs.assess_controller_handoff).
**The compact format is the default.** It is written for controller-LLM
readability, not as a full human status report. PR bodies still carry the
full review detail — the handoff never replaces PR documentation.
Compact format (default, canonical field set per issue #182):
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Issue/PR:
- Branch/SHA:
- Files changed:
- Validation:
- Mutations:
- Current status:
- Blockers:
- Next:
- Safety:
```
Every task must end with a section titled exactly `Controller Handoff`. Compact
format canonical field set per issue #182; mode-specific schemas in
`schemas/*-final-report.md` define required fields. Use the final report schema
for the loaded workflow mode — not the legacy compact block alone.
`review_proofs.assess_controller_handoff()` validates presence.
Role-specific fields (append to the compact block):
## Prompt templates
- review/merge tasks: `Selected PR:`, `Reviewer eligibility:`,
`Pinned reviewed head:`, `Review decision:`, `Merge result:`,
`Linked issue status:`, `Cleanup status:`
- author tasks: `Selected issue:`, `Claim/comment status:`,
`PR number opened:`, `No review/merge:` (explicit confirmation)
- queue/inventory tasks: `Repositories checked:`, `Open PR counts:`,
`Selected PR or reason none selected:`, `Inventory completeness:`
Ready-to-copy task prompts live in [`templates/`](templates/):
The section title must be exactly `Controller Handoff`.
`review_proofs.assess_controller_handoff()` validates this section; reports
missing it (or missing required fields) are downgraded. The handoff never
replaces the full report — it is the compact continuation summary at the end,
and the full report must still carry exact validation results and mutation
confirmation.
The `Safety:` line is never omitted; it is usually:
```text
no self-review; no self-merge; no tags; no secrets; no prod
```
Rules (both formats):
- Never omit the handoff, and never omit the safety confirmations.
- Never bury blockers in earlier text only — they must appear here.
- If you opened a PR, state clearly that review is needed.
- If you reviewed but could not merge, name the exact gate that blocked it.
- If you only commented on a discussion issue, say no code review is needed
but owner/design feedback may be needed.
- If release state was touched, state exactly which tag/commit changed and why.
- If blocked (permissions, missing repo, missing second reviewer identity,
stale dependency, unclear tracker home): stop and report clearly; **never
bypass classifiers, profile gates, missing permissions, or live-consent
requirements**; give the owner concrete options.
**Use the long format below instead of the compact one only when the task was
high-risk or complex** — i.e. when any of these happened:
- a merge, tag, or release
- failed validation
- permissions/profile gates blocked work
- secrets or production access were involved
- a complicated owner decision
- multiple repos or cross-issue state
- the owner explicitly asks for the full format
Long format (high-risk/complex tasks only):
```md
## Controller Handoff Summary
### Work performed
Briefly state what was done.
### Current state
Include:
- current repo
- current branch or master commit
- issue number(s)
- PR number(s), if any
- whether work is complete, blocked, ready for review, or discussion-only
### Files changed
List files changed, or say `None`.
### Validation
List commands run and results, or say `Not applicable — discussion only`.
### Issues encountered
List errors, confusing state, permission/profile problems, stale branches,
failing tests, missing labels, or blocked decisions.
### Review needed?
Say one of:
- `No review needed — discussion/comment only`
- `Review needed — PR is open`
- `Independent non-author review needed`
- `Owner decision needed`
- `Blocked`
### Next recommended action
State exactly what should happen next.
### Safety confirmations
Confirm:
- no self-review
- no self-merge
- no release/tag changes unless explicitly requested
- no secrets committed
- no production access used unless explicitly authorized
```
### Example blocked handoff
```md
## Example blocked handoff
### Work performed
Audited phase-2 MCP Control Plane planning. Found target repo
`mcp-control-plane` does not exist. Prepared issue pack but did not file it.
### Current state
- Repo: `Scaled-Tech-Consulting/Gitea-Tools`, unmodified
- Target repo: `mcp-control-plane`, missing
- Issues: none open in Gitea-Tools
- PRs: none open
- Status: blocked pending owner decision
### Files changed
None.
### Validation
Tracker/repo audit only. No code validation required.
### Issues encountered
Repo creation was denied by permission/classifier because it would be scope
escalation without live consent.
### Review needed?
Owner decision needed.
### Next recommended action
Owner must choose:
1. create `Scaled-Tech-Consulting/mcp-control-plane`
2. authorize repo creation while present
3. file phase-2 issues in Gitea-Tools instead
### Safety confirmations
- no self-review
- no self-merge
- no release/tag changes
- no secrets committed
- no production access used
```
- [`start-issue.md`](templates/start-issue.md) — author work (loads `work-issue.md`)
- [`review-pr.md`](templates/review-pr.md) — review (loads `review-merge-pr.md`)
- [`merge-pr.md`](templates/merge-pr.md) — merge (loads `review-merge-pr.md`)
- [`recover-bad-state.md`](templates/recover-bad-state.md)
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md)
- [`worktree-cleanup.md`](templates/worktree-cleanup.md)
- [`release-tag.md`](templates/release-tag.md)
## Adapting to a project
Replace these project-specific names when copying the skill elsewhere:
| Placeholder | Meaning | Example here |
|-------------|---------|--------------|
| `<remote>` | Git remote for the forge | `prgs` |
| default branch | Integration branch | `master` |
| profile env vars | Canonical config + profile selectors | `GITEA_MCP_CONFIG`, `GITEA_MCP_PROFILE` |
| `branches/` | Ignored worktree directory | `branches/` |
| helper scripts | Worktree helpers | `scripts/worktree-start` / `-review` / `-clean` |
The rules in §A–§K are project-agnostic and should not change.
| Placeholder | Example here |
|-------------|--------------|
| `<remote>` | `prgs` |
| default branch | `master` |
| profile env vars | `GITEA_MCP_CONFIG`, `GITEA_MCP_PROFILE` |
| `branches/` | `branches/` |
| helpers | `scripts/worktree-start` / `-review` / `-clean` |
## Versioning And Tagging
Releases follow SemVer: **`vMAJOR.MINOR.PATCH`** (use **`v0.x.y`** while
unstable). Choose the bump by the largest change since the last tag:
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
- **MINOR** — new tools/helpers/config features; backward-compatible behavior.
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
Tags must:
- be created **only from `master`** (the exact commit on remote `master`),
- be created **only after the full test suite passes**,
- be **annotated** tags (`git tag -a`), never lightweight,
- include release notes / a changelog summary referencing the merged PRs/issues.
**Never tag** feature branches, dirty worktrees, unreviewed or self-authored
work, or commits not present on remote `master`.
Additional tag rules:
- Do **not** create, move, delete, or push tags unless explicitly instructed.
- Tag only **after** the intended PR is merged, and tag only the **verified
final master merge commit** (never the PR branch head unless the merge
commit is exactly that commit).
- Always **report the tag target commit** in the final report / handoff.
Release process (see [`templates/release-tag.md`](templates/release-tag.md)):
1. `git fetch <remote> --prune`.
2. Verify local `master` equals remote `master` (`0 0`) and the tree is clean.
3. Run the full test suite; stop on any failure.
4. Inspect merged issues/PRs since the last tag
(`git log --oneline <last-tag>..<remote>/master`).
5. Choose the version bump.
6. Create the annotated tag on remote `master` with release notes.
7. Push the tag.
8. Create/update release notes if the forge supports it.
Where present, `scripts/release-tag` automates this with all gates built in
(SemVer, fetch/prune, on-master, clean tree, local==remote master, HEAD on
remote master, no duplicate tag, tests, annotated-only). Safe by default: no
push without `--push`; `--dry-run` changes nothing; `--skip-tests` must be
explicit and warns.
Releases follow SemVer from remote `master` only, after full test suite passes.
See [`templates/release-tag.md`](templates/release-tag.md) and
`scripts/release-tag`.
@@ -0,0 +1,46 @@
# Create-issue controller handoff schema
**Task mode:** `create-issue`
End every create-issue run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Do not use legacy fields: `Workspace mutations`, `Mutations: None` (when
mutations occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Requested issue task:
- Workflow source:
- Capability proof:
- Duplicate search terms:
- Duplicate search pagination proof:
- Duplicates found:
- Issues created:
- Issues commented:
- Issues edited:
- Issues skipped as duplicates:
- Labels/assignees/milestones changed:
- File edits by issue creator:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Issue mutations:
- Label/assignment/milestone mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
```
Identity format: `username / profile` (not personal email unless required — #305).
@@ -0,0 +1,53 @@
# Reconcile-landed controller handoff schema
**Task mode:** `reconcile-landed-pr`
End every reconciliation run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Reject stale author/reviewer fields: `PR number opened`, `Pinned reviewed head`,
`Scratch worktree used`, `Workspace mutations`, `Mutations: None` (when mutations
occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Selected PR:
- PR live state:
- Candidate head SHA:
- Target branch:
- Target branch SHA:
- Ancestor proof:
- Linked issue:
- Linked issue live status:
- Eligibility class:
- Capabilities proven:
- Missing capabilities:
- PR comments posted:
- Issue comments posted:
- PRs closed:
- Issues closed:
- File edits by reconciler:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Reconciliation mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
- No review/merge confirmation:
```
Identity format: `username / profile` (not personal email unless required — #305).
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
@@ -0,0 +1,94 @@
# Review-merge controller handoff schema
**Task mode:** `review-merge-pr`
End every review/merge run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Do not use legacy fields: `Pinned reviewed head`, `Scratch worktree used`,
`Workspace mutations`, `Mutations: None` (when mutations occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Selected PR:
- Linked issue:
- Eligibility class:
- Queue ordering policy:
- Inventory pagination proof:
- Earlier PRs skipped:
- Candidate head SHA:
- Reviewed head SHA:
- Target branch:
- Target branch SHA:
- Already-landed gate:
- Author-safety result:
- Prior request-changes state:
- Review worktree used:
- Review worktree path:
- Review worktree inside branches:
- Review worktree HEAD state:
- Review worktree dirty before validation:
- Review worktree dirty after validation:
- Baseline worktree used:
- Baseline worktree path:
- Files reviewed:
- Validation:
- Official validation integrity status:
- Terminal review mutation:
- Review decision:
- Merge preflight:
- Merge result:
- Linked issue status:
- Main checkout branch:
- Main checkout dirty state:
- Main checkout updated:
- File edits by reviewer:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Review mutations:
- Merge mutations:
- Cleanup mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
```
### Already-landed handoff overrides
When eligibility class is `ALREADY_LANDED_RECONCILE_REQUIRED`:
- Reviewed head SHA: `none`
- Review worktree used: `false`
- Review worktree path: `none`
- Review decision: `none`
- Merge result: `none`
Identity format: `username / profile` (not personal email unless required — #305).
### Queue-status-only runs (no selected PR)
When the run inventories the queue but selects no PR for review:
- Selected PR: `none`
- Already-landed gate, Author-safety result, Merge preflight: `not applicable` or `not run` — never `passed`
- Review worktree detail fields: `not applicable` or `none` when Review worktree used is `false`
- Blockers must not be `none` if the narrative says all open PRs are conflicted, blocked, or unverified
- Inventory pagination proof must cite final-page metadata, not default page-size assumptions
Verifier: `review_proofs.assess_queue_status_report()`.
Narrative final report and controller handoff must agree on eligibility class,
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
terminal review mutation, merge result, and linked issue status.
@@ -0,0 +1,73 @@
# Work-issue controller handoff schema
**Task mode:** `work-issue`
End every work-issue run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Do not use legacy fields: `Workspace mutations`, `Mutations: None` (when
mutations occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Selected issue:
- Eligibility class:
- Issue ordering policy:
- Issue inventory pagination proof:
- Earlier issues skipped:
- Duplicate active work proof:
- Claim/lock state:
- Stable branch:
- Stable branch SHA:
- Branch name:
- Worktree path:
- Worktree inside branches:
- Worktree branch/HEAD state:
- Worktree dirty before implementation:
- Files changed:
- Validation:
- Baseline comparison:
- Commit SHA:
- Push result:
- PR number:
- PR URL:
- PR verification:
- Main checkout branch:
- Main checkout dirty state:
- Main checkout used for task work:
- File edits by author:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Issue mutations:
- Branch mutations:
- Commit mutations:
- Push mutations:
- PR mutations:
- Cleanup mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
```
Identity format: `username / profile` (not personal email unless required — #305).
Narrative final report and controller handoff must agree on eligibility class,
selected issue, and mutation ledger categories (#319, #320).
`git fetch` and ref-updating commands belong under `Git ref mutations`, not
`Read-only diagnostics` (#297).
Forbidden claims without proof (#330): `next eligible issue`, `issue claimed`,
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
@@ -5,6 +5,10 @@ Copy, fill the `<...>` fields, and paste as the task prompt.
```text
Task: merge PR #<pr> for issue #<n> if it is eligible and checks pass.
Load the canonical workflow first:
`skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr).
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- Only an eligible, NON-author reviewer merges. If authenticated user == PR
author → STOP.
@@ -17,9 +17,36 @@ Repo name disambiguation (Gitea-Tools blind review hardening):
configured repos were not checked. This is not a complete queue inventory."
- A single-repo "no open PRs" result MUST NOT be reported as global "no open PRs"
if the other configured repo was not inventoried.
- PR inventory trust gate (#196): before reporting "no open PRs" or "queue empty",
the workflow must run `pr_inventory_trust_gate` (via the live inventory path or
`review_proofs.assess_reviewer_queue_inventory`). Only `trusted_empty` allows a
clean empty-queue stop. Report `pr_inventory_trust_gate.status`, reasons, and
corroboration in the final report. A bare `[]` from `gitea_list_prs` is never
sufficient proof.
- Empty-queue report wall (#198): if the final report claims "no open PRs",
"queue empty", or "nothing to review", it must include verbatim:
`pr_inventory_trust_gate.status`, trust-gate reasons, corroboration,
remote/owner/repo/state filter, and the inventory MCP profile. A recent merge
commit is not valid corroboration. Author-bound sessions must not present
reviewer queue inventory as a reviewer decision.
Load the canonical workflow first:
`skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr).
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- Review in a SEPARATE detached review worktree, never the author's folder.
- Worktree safety (#233): before checkout, diff, validation, review, or merge,
report the starting worktree path and whether it was dirty. If unrelated
tracked files exist outside the PR scope, STOP or run
`scripts/worktree-review <pr-head-branch>` and validate in the scratch path.
Scratch-clone validation is the norm; tests must not assume the shared
development worktree or a repo-local ``venv/`` (#245).
NEVER run `git stash`, `git stash pop/drop`, `git checkout --`, `git reset`,
or `git clean` to manage another session's dirty files.
- Final report must state: Worktree path, Worktree dirty (yes/no),
Scratch worktree used (yes/no + path if yes), and confirm no unrelated local
files were modified, stashed, reset, or dropped.
- You must NOT be the PR author. If the authenticated user == PR author, stop.
A different LLM-Agent-SHA does NOT make you a different actor — only a
different authenticated Gitea user does (docs/llm-agent-sha.md).
@@ -5,6 +5,10 @@ Copy, fill the `<...>` fields, and paste as the task prompt.
```text
Task: implement <issue title / one-line goal>.
Load canonical workflow: skills/llm-project-workflow/workflows/work-issue.md
Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report.md
Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue)
Rules (llm-project-workflow):
- No repo changes without a tracking issue. If none exists, create one first;
if it can't be created, stop.
@@ -13,6 +17,21 @@ Rules (llm-project-workflow):
- Do not self-review or self-merge.
Steps:
0. Work Selection Rule — before any claim, branch, or file edits, acquire or
verify a work lease. Required checks: list open PRs; search PRs linked to
the target issue; search local/remote branches for the issue number; search
registered worktrees for the issue branch; check dirty worktrees; check
active leases or recent handoffs; check whether a merged PR already
completed the issue. If another session owns the lease, stop (continue only
as lease owner, review the existing PR, hand off, request takeover after
expiry, or report "work already claimed"). Never open a parallel branch/PR
unless the old branch is proven abandoned and takeover is recorded.
0b. Global LLM Worktree Rule — before any mutation, prove and state: project
root; cwd; current branch; stable branch for the main checkout (master/main/dev);
session-owned worktree path under branches/. If cwd is not inside branches/,
STOP (no exceptions — not for docs, tests, small fixes, review fixes, conflicts,
or cleanup). Main checkout is control-only: read-only inspect, fetch, create
worktrees, stable-branch update after merge, explicit repair.
1. Identity Checklist: Before claiming work, verify and state:
- Required identity/profile for this task: author (allowed to push branches / create PRs)
- Current authenticated identity (from whoami): <username>
@@ -0,0 +1,666 @@
---
task_mode: create-issue
canonical: true
final_report_schema: ../schemas/create-issue-final-report.md
---
# Create issue workflow (canonical)
**Task mode:** `create-issue`
This file is the canonical issue-creation workflow for Gitea-Tools. Load it
before any issue mutation. Final report schema:
[`schemas/create-issue-final-report.md`](../schemas/create-issue-final-report.md).
**Default task prompt:**
> Create or update Gitea issues in this project only if every identity,
> capability, duplicate-search, issue-scope, final-report, mutation-ledger,
> and proof-wording gate passes.
Do not improvise around the gates. Follow project skills, MCP gates, and
workflow rules exactly.
This is an issue-creation workflow. It is not a reviewer workflow and not an
implementation workflow.
---
## 0. Load the canonical workflow first
Before starting issue creation or issue update work, check whether the project provides a canonical create-issue workflow through a project skill, runbook, or MCP helper.
If available, load it first and report:
* workflow source
* workflow version, commit, or hash
* whether this prompt conflicts with the loaded workflow
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
## 1. Mode isolation
This run is `create-issue` mode only.
Do not:
* review PRs
* approve PRs
* request changes
* merge PRs
* close PRs
* close issues unless the user explicitly asks and exact close capability is proven
* implement code
* edit repo files
* create branches
* create commits
* push branches
* create PRs
* run tests unless the canonical workflow explicitly requires validation for issue creation
* perform reviewer-only actions
* perform author/coder-only actions
* perform MCP repair
If the task requires review, merge, issue implementation, or MCP repair mode, stop and produce a handoff for the correct workflow.
Do not mix modes in one run.
## 2. Start with live identity, profile, runtime, and capability checks
Prove:
* authenticated identity
* active profile
* repo/project
* runtime context
* exact capability for reading/searching issues
* exact capability for creating issues, if creating issues
* exact capability for commenting on issues, if commenting on existing issues
* exact capability for editing issues, if editing existing issues
* exact capability for applying labels, if applying labels
* exact capability for assigning issues, if assigning issues
* exact capability for closing issues, only if explicitly requested
A nearby capability does not count.
Examples:
* `create_issue` does not authorize `issue_comment`
* `issue_comment` does not authorize `create_issue`
* `create_pr` does not authorize `create_issue`
* `review_pr` does not authorize `create_issue`
* `merge_pr` does not authorize `issue_comment`
* `gitea.read` does not authorize creating, commenting, editing, labeling, assigning, or closing issues
If exact capability cannot be proven, stop and produce a recovery handoff only.
## 3. Stop immediately on blocked infrastructure
If any of the following appears, stop immediately:
* `infra_stop`
* MCP reconnect failure
* stale capability state
* missing capability
* workspace mismatch
* dirty control checkout, if the canonical workflow treats that as blocking
* broken canonical workflow loading
* failed required preflight
* capability resolver warning that says the current state may be unsafe
* stale or inconsistent runtime context
Do not continue duplicate search, issue creation, issue commenting, issue editing, labeling, assignment, or cleanup.
Produce an executable recovery handoff only.
Blocked recovery handoffs must not include direct issue-create or issue-comment replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 4. Main checkout rule
This workflow should not mutate repo files.
Do not edit files in the main checkout.
Do not create branches.
Do not create commits.
Do not push.
Do not run implementation work.
Do not run reviewer validation.
Reading repository files is allowed only when needed to understand the requested issue and only if the canonical workflow permits it.
If the main checkout is dirty and the project treats dirty control checkout as blocking, stop and produce a recovery handoff.
## 5. No raw MCP repair during issue creation
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during issue creation.
If MCP repair is required, stop issue creation and produce a separate `CONTROL-CHECKOUT REPAIR MODE` handoff.
Do not mix MCP repair mode with create-issue mode.
After repair, rerun the full workflow from the beginning.
## 6. No background task tools
Do not use `schedule`, `manage_task`, background jobs, async waits, delayed task tools, or monitoring tasks during issue creation.
Use direct commands and MCP tools only.
If a required action cannot complete synchronously, stop and produce a recovery handoff.
Do not say “I will check later,” “I will monitor,” or “I will continue in the background.”
## 7. No local Gitea fallback during normal issue creation
During normal issue-creation workflows, do not read Gitea profile secret files.
Do not inspect or open files such as:
* `profiles.json`
* local token stores
* credential files
* local Gitea auth/profile config files
* `.env` files containing Gitea credentials
* keychain dumps
* token helper outputs
Do not run local Gitea helper scripts when MCP tools are available.
Use MCP tools for Gitea operations.
Local fallback is allowed only in explicit recovery mode when MCP is unavailable and identity/profile/capability can be independently proven.
If local fallback is used, report:
* why MCP was unavailable
* exact identity proof
* exact profile proof
* exact repo proof
* exact capability proof
* exact local command used
Do not use local fallback to bypass MCP gates.
## 8. Understand the requested issue work
Before searching or creating issues, restate the requested issue-creation task in operational terms.
Identify:
* target repo/project
* issue topic
* issue type, if known
* whether this is a new issue, duplicate check, issue update, or issue-comment task
* whether the user provided exact title/body text
* whether acceptance criteria were provided
* whether multiple issues are requested
* whether labels, assignees, milestones, or links are requested
* whether any requested action requires capability beyond issue creation
Do not invent missing requirements.
If the request is ambiguous but safe to proceed, make a reasonable best-effort issue with clear assumptions.
If ambiguity would cause unsafe or wrong mutation, stop and ask for clarification or produce a recovery handoff according to project policy.
## 9. Duplicate search before mutation
Before creating any issue, search open and closed issues for duplicates.
Duplicate search must happen before each issue creation unless a batch search clearly covers all proposed issues.
Search terms must include:
* exact proposed issue title
* key noun phrase from the problem
* key workflow/tool name
* key failure phrase or error phrase
* likely alternate wording
* linked PR number, issue number, or file name, if relevant
If the user supplied search terms, use those too.
For each proposed issue, report:
* search terms used
* matching issue numbers/titles
* whether each match is open or closed
* whether any match fully covers the requested issue
* whether any match partially covers the requested issue
* whether a new issue is still needed
Do not create a duplicate issue if an existing issue fully covers the problem.
If a duplicate exists and fully covers the problem, stop issue creation for that topic and report the duplicate.
If a duplicate exists but is missing important acceptance criteria, comment on the existing issue only if exact `issue_comment` capability is proven and the user/task authorizes commenting.
If commenting is not authorized, report the existing issue and the missing criteria in the final handoff.
## 10. Issue inventory pagination rule
If listing/searching issues returns paginated results, follow pagination until the tool proves there are no more pages.
Do not assume search results or issue inventory are complete.
Pagination proof must not rely on assumed default API page size.
Search/inventory is complete only if one of the following is proven:
* the MCP response explicitly says there is no next page / `has_more=false` / final page
* the workflow traversed pages until an empty page or explicit final page was returned
* the tool response includes total-count or pagination metadata proving all relevant issues were returned
* the request explicitly set `page` / `limit` / `per_page`, and the response explicitly proves the server honored that page size and did not truncate results
Do not say “duplicate search complete” merely because the result count is less than an assumed default page size.
If pagination metadata is absent and the tool cannot page, report `ISSUE_SEARCH_PAGINATION_UNPROVEN`.
If duplicate search cannot be trusted, do not create the issue unless the canonical workflow explicitly permits best-effort issue creation with that limitation disclosed.
## 11. Issue creation scope rule
Create only issues within the requested scope.
Do not create extra issues just because related problems are noticed.
Do not create process-hardening issues unless the user explicitly requested process-hardening or the current task is explicitly about workflow/tooling gaps.
Do not create implementation issues during reviewer mode.
Do not create reviewer issues during work-on-issue mode.
Do not create issues in a different repository unless the user explicitly asked and exact capability is proven.
If multiple issues are requested, create only the requested issues and only after duplicate search for each one.
If a proposed issue is too broad, split it only if the user requested splitting or the canonical workflow requires issue granularity.
## 12. Issue content quality rule
Every created issue must be actionable.
Include, when applicable:
* title
* problem statement
* observed evidence
* expected behavior
* required behavior
* acceptance criteria
* affected workflow/tool/files
* safety or security considerations
* duplicate search summary
* related issues or PRs
* non-goals, if useful
Acceptance criteria must be concrete and testable.
Avoid vague issues like:
* “make workflow better”
* “fix LLM behavior”
* “improve process”
* “handle this better”
Instead, describe the exact wall, gate, verifier, test, schema, helper, or prompt change required.
## 13. Issue title rule
Use concise, specific titles.
Good title patterns:
* `Enforce <specific gate>`
* `Add verifier for <specific report/proof problem>`
* `Split <large workflow> into <specific components>`
* `Block <unsafe action> during <workflow mode>`
* `Require <proof type> before <claim/action>`
Avoid titles that are too broad or emotional.
The title should be unique enough that duplicate search can find it later.
## 14. Issue body rule
Issue body must include the full acceptance criteria.
Do not create placeholder issues.
Do not create issues with only a title unless the user explicitly requested title-only creation.
If the user supplied exact issue body text, preserve it unless it contains unsafe instructions, stale facts, or contradictions.
If edits are needed, make the smallest correction necessary and report the correction.
Do not silently change requested meaning.
## 15. Labels, assignees, and metadata
Apply labels, assignees, milestones, or project fields only if:
* the user requested them, or
* the canonical workflow requires them, and
* exact capability is proven.
Do not guess labels if project label policy is unknown.
If labels are useful but capability or policy is unclear, mention recommended labels in the final report instead of applying them.
Do not assign issues to people unless explicitly requested or required by project workflow.
## 16. Comment-on-existing issue rule
Comment on an existing issue only if:
* an existing issue partially covers the requested work, or
* the user asked to add information to an existing issue, or
* the canonical workflow requires duplicate consolidation comments, and
* exact `issue_comment` capability is proven.
Comment must be specific and useful.
Include:
* why the existing issue is relevant
* what acceptance criteria or evidence should be added
* whether this avoids creating a duplicate
Do not comment just to say “duplicate found” unless the project workflow requires it.
Do not close duplicate issues unless explicitly requested and exact close capability is proven.
## 17. No hidden mutations
Do not perform unreported mutations.
Every issue creation, issue comment, issue edit, label change, assignment, milestone change, close/reopen action, or external-state change must be reported.
If a tool call is dry-run-only, confirmation-gated, rejected, or no-op, report it separately from performed mutations.
A dry run is not a mutation.
A rejected call is not a performed mutation.
A successful issue creation is an issue mutation.
A successful issue comment is an issue mutation.
A successful label/assignment/milestone update is an issue mutation or external-state mutation.
## 18. Issue creation gate
Before creating each issue, verify:
* identity is still valid
* active profile is still valid
* runtime context is still safe
* exact `create_issue` capability is still valid
* duplicate search was completed or limitation was explicitly allowed
* proposed title is not a duplicate
* proposed body includes actionable acceptance criteria
* target repo is correct
* no mode switch has occurred
If any gate fails, do not create the issue.
Produce a recovery handoff or duplicate report.
## 19. Issue commenting gate
Before commenting on an existing issue, verify:
* identity is still valid
* active profile is still valid
* runtime context is still safe
* exact `issue_comment` capability is still valid
* target issue number is correct
* comment body is specific and useful
* comment will not duplicate an existing comment
* no mode switch has occurred
If any gate fails, do not comment.
Produce a recovery handoff or report the intended comment as a recommendation only.
## 20. Issue edit/update gate
Before editing an existing issue, verify:
* identity is still valid
* active profile is still valid
* runtime context is still safe
* exact edit capability is still valid
* target issue number is correct
* update is explicitly requested or required by canonical workflow
* update does not erase useful existing content
* no mode switch has occurred
If any gate fails, do not edit.
Prefer commenting over editing unless the user explicitly requested an edit or the canonical workflow requires issue body updates.
## 21. Final report must be precise
Include:
* canonical workflow source/version/hash, if available
* authenticated identity/profile
* repo/project
* runtime context summary
* exact capability proof summary
* requested issue-creation task
* duplicate search terms used
* duplicate search result
* pagination/final-page proof for issue search, if applicable
* issues created, with issue numbers and URLs
* existing issues commented, with issue numbers and URLs
* existing issues edited, with issue numbers and URLs
* issues skipped as duplicates, with issue numbers and titles
* labels/assignees/milestones applied, if any
* blockers, if stopped
* confirmation that no PR review, approval, request-changes, merge, branch, checkout, commit, push, or repo-file mutation was performed
If the report and actual tool/command log disagree, fix the report before final output.
## 22. Final report must distinguish mutation types
Do not use the legacy field `Workspace mutations`.
Use only precise categories:
* File edits by issue creator:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Label/assignment/milestone mutations:
* External-state mutations:
* Read-only diagnostics:
Use precise wording:
* `File edits by issue creator: none`
* `Worktree/index mutations: none`
* `Git ref mutations: none`
* `Issue mutations: ...`
If no repo files were edited, say:
`File edits by issue creator: none`
If no branches/worktrees were touched, say:
`Worktree/index mutations: none`
If no git refs were updated, say:
`Git ref mutations: none`
Do not hide issue mutations inside vague `MCP/Gitea mutations`.
## 23. Local artifact and report consistency rule
Do not create local walkthrough, notes, markdown, JSON, or report artifacts during issue-creation runs unless the canonical workflow or operator explicitly requires it.
If any file is edited, created, generated, or written, report it under `File edits by issue creator`.
For each file write, report:
* exact path
* whether it was inside the repo
* whether it was tracked or untracked
* why it was created
* whether final status was checked after the write
Do not say `File edits by issue creator: none` if any file write occurred.
Do not write files after the final clean-status check unless you rerun and report a new final clean-status check.
Default behavior: do not create local artifacts during issue creation.
## 24. Forbidden final-report claims unless proven
Do not claim:
* `duplicate search complete`
* `no duplicate found`
* `issue created`
* `issue commented`
* `issue updated`
* `label applied`
* `capability proven`
* `runtime safe`
* `all gates passed`
* `no file edits`
* `no unsafe mutation`
* `no PR mutation`
* `no repo mutation`
* `pagination complete`
* `final page`
* `no next page`
unless the corresponding proof is included.
If anything blocks safe issue creation or issue update, stop immediately and produce an executable recovery handoff.
Do not improvise around the gates.
## 25. Proof wording enforcement
The following phrases are forbidden unless directly supported by current-session evidence:
* duplicate search complete
* no duplicate found
* issue created
* issue commented
* issue updated
* labels applied
* capability proven
* runtime safe
* all gates passed
* no file edits
* no unsafe mutation
* no PR mutation
* no repo mutation
* pagination complete
* final page
* no next page
If the proof comes from prior state rather than a command/tool run in the current session, label it as prior proof, not live proof.
If a tool call was rejected, confirmation-gated, dry-run-only, or no-op, report it separately from performed mutations.
## 26. Final self-check before output
Before final output, check the report for contradictions.
Verify:
* if any file was edited, `File edits by issue creator` is not `none`
* if any worktree was added/removed, `Worktree/index mutations` lists it
* if any fetch happened, `Git ref mutations` lists it
* if any issue was created, `Issue mutations` lists it
* if any issue was commented, `Issue mutations` lists it
* if any issue was edited, `Issue mutations` lists it
* if any labels/assignees/milestones were changed, the correct mutation category lists it
* if duplicate search is claimed complete, pagination/final-page proof is present or limitation is disclosed
* if no duplicate is claimed, search terms and results are present
* if issue created is claimed, issue number and URL are present
* if no PR mutation is claimed, no PR tool/action was used
* if no repo mutation is claimed, no branch/worktree/file/commit/push action occurred
* if all gates passed is claimed, every required gate has proof
If any contradiction exists, fix the final report before output.
## 27. Controller handoff schema
End every run with a controller handoff using this schema.
Do not omit fields. Use `none` or `not verified in this session` where appropriate.
Controller Handoff:
* Task:
* Repo:
* Role:
* Identity:
* Active profile:
* Runtime context:
* Requested issue task:
* Workflow source:
* Capability proof:
* Duplicate search terms:
* Duplicate search pagination proof:
* Duplicates found:
* Issues created:
* Issues commented:
* Issues edited:
* Issues skipped as duplicates:
* Labels/assignees/milestones changed:
* File edits by issue creator:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Label/assignment/milestone mutations:
* External-state mutations:
* Read-only diagnostics:
* Blockers:
* Current status:
* Safe next action:
* Safety statement:
## 28. Stop conditions summary
Stop immediately and produce a recovery handoff if:
* canonical workflow is required but cannot be loaded
* identity/profile/capability cannot be proven
* runtime context is blocked
* infra stop appears
* MCP reconnect fails
* capability state is stale
* duplicate search cannot be performed and best-effort creation is not allowed
* issue search pagination cannot be proven and best-effort creation is not allowed
* duplicate fully covers the requested issue
* requested issue body is unsafe or not actionable
* target repo cannot be proven
* create_issue capability is missing
* issue_comment capability is missing for a required comment
* issue edit capability is missing for a required edit
* mode switch would be required
* any report contradiction cannot be resolved
Blocked handoffs must not include direct issue-create or issue-comment replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
Do not improvise around the gates.
@@ -0,0 +1,387 @@
---
task_mode: reconcile-landed-pr
canonical: true
final_report_schema: ../schemas/reconcile-landed-final-report.md
---
# Reconcile already-landed open PR workflow (canonical)
**Task mode:** `reconcile-landed-pr`
This file is the canonical reconciliation workflow for open PRs whose head SHA
is already an ancestor of the target branch. Load it before any reconciliation
mutation. Final report schema:
[`schemas/reconcile-landed-final-report.md`](../schemas/reconcile-landed-final-report.md).
**Default task prompt:**
> Reconcile already-landed open PRs in this project. Do not review or merge
> normal PRs. Close or comment only when exact capability is proven.
Do not improvise around the gates. Follow project skills, MCP gates, and
workflow rules exactly.
This is a reconciliation workflow. It is not a normal PR review/merge workflow.
---
## 0. Load the canonical workflow first
Before starting reconciliation work, check whether the project provides a
canonical reconcile-landed-PR workflow through a project skill, runbook, or MCP
helper.
If available, load it first and report:
* workflow source
* workflow version, commit, or hash
* whether this prompt conflicts with the loaded workflow
If the canonical workflow cannot be loaded and the project requires it, stop and
produce a recovery handoff only.
## 1. Mode isolation
This run is `reconcile-landed-pr` mode only.
**Do not review or merge normal PRs.**
Do not:
* approve PRs
* request changes on PRs
* merge PRs
* implement code
* edit repo files
* create branches
* create commits
* push branches
* create PRs
* run normal PR validation as review approval input
* perform author/coder implementation work
* perform raw MCP repair
If the task requires review, merge, issue implementation, or MCP repair mode,
stop and produce a handoff for the correct workflow.
Do not mix modes in one run.
## 2. Start with live identity, profile, runtime, and capability checks
Prove:
* authenticated identity
* active profile (reconciler or author with close capabilities, as required)
* repo/project
* runtime context
* exact capability for reading/listing PRs and issues
* exact capability for PR inspect (`gitea.read` / view PR)
* exact capability for issue inspect
* exact capability for PR comment, if commenting
* exact capability for issue comment, if commenting
* exact capability for PR close, if closing PRs
* exact capability for issue close, if closing issues
A nearby capability does not count.
Examples:
* `review_pr` does not authorize PR close
* `merge_pr` does not authorize PR close or issue close
* `create_issue` does not authorize `issue_comment`
* `issue_comment` does not authorize PR close
* `gitea.read` does not authorize close or comment mutations
If exact capability cannot be proven, stop and produce a recovery handoff only.
## 3. Stop immediately on blocked infrastructure
If any of the following appears, stop immediately:
* `infra_stop`
* MCP reconnect failure
* stale capability state
* missing capability
* workspace mismatch
* broken canonical workflow loading
* failed required preflight
* capability resolver warning that says the current state may be unsafe
* stale or inconsistent runtime context
Do not continue inventory, ancestry proof, commenting, closing, or cleanup.
Produce an executable recovery handoff only.
Blocked recovery handoffs must not include direct close or comment replay
commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 4. Main checkout rule
This workflow should not mutate repo files.
Do not edit files in the main checkout.
Do not create branches, commits, or pushes.
Do not run implementation or reviewer validation worktrees for code edits.
Reading repository files is allowed only when needed to understand
reconciliation scope and only if this workflow permits it.
## 5. No raw MCP repair during reconciliation
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or
perform control-checkout repair during reconciliation.
If MCP repair is required, stop and produce a separate `CONTROL-CHECKOUT REPAIR
MODE` handoff.
After repair, rerun the full workflow from the beginning.
## 6. No background task tools
Do not use `schedule`, `manage_task`, background jobs, async waits, delayed task
tools, or monitoring tasks during reconciliation.
Use direct commands and MCP tools only.
If a required action cannot complete synchronously, stop and produce a recovery
handoff.
## 7. No local Gitea fallback during normal reconciliation
During normal reconciliation workflows, do not read Gitea profile secret files.
Do not inspect `profiles.json`, local token stores, credential files, `.env`
Gitea credentials, keychain dumps, or token helper outputs.
Do not run local Gitea helper scripts when MCP tools are available.
Use MCP tools for Gitea operations.
Local fallback is allowed only in explicit recovery mode when MCP is unavailable
and identity/profile/capability can be independently proven.
## 8. Build a complete live open PR inventory
List open PRs for the target repo according to project policy.
Follow pagination until the tool proves there are no more pages.
Do not assume inventory is complete.
Pagination proof must not rely on assumed default API page size.
Inventory is complete only if one of the following is proven:
* the MCP response explicitly says there is no next page / `has_more=false` /
final page
* the workflow traversed pages until an empty page or explicit final page was
returned
* the tool response includes total-count or pagination metadata proving all
relevant PRs were returned
* the request explicitly set `page` / `limit` / `per_page`, and the response
explicitly proves the server honored that page size and did not truncate results
If pagination cannot be proven, report `INVENTORY_PAGINATION_UNPROVEN` and stop
unless project policy allows best-effort reconciliation with that limitation
disclosed.
## 9. Already-landed proof
For each candidate PR, prove whether the PR head SHA is already landed on the
target branch.
**Already-landed proof** must include:
* PR number and title
* candidate head SHA (full 40-hex)
* target branch name
* target branch SHA (full 40-hex) after fetch
* ancestor proof method (`git merge-base --is-ancestor`, equivalent forge API, or
documented project helper)
* ancestor proof result (true/false)
* live PR state (open/closed, merged flag)
Do not classify a PR as already-landed without live ancestor proof.
If ancestry cannot be proven, classify as `ANCESTRY_UNPROVEN` and skip close
mutations.
## 10. Linked issue live verification
If the PR claims to close or link an issue, fetch the linked issue live before
reporting its status.
If the linked issue was not fetched live in the current session, report:
`Linked issue status: not verified in this session`
Do not claim `issue open`, `issue closed`, or `issue resolved` without live
proof.
## 11. Reconciliation selection rules
Select PRs eligible for reconciliation:
* open PR state
* `merged=false` unless project policy says otherwise
* head SHA is ancestor of target branch (already-landed proof passed)
* not selected for normal review/merge in this run
Eligibility class for selected PRs: `ALREADY_LANDED_RECONCILE_REQUIRED`
Do not select PRs that fail already-landed proof for normal review/merge
treatment in this mode.
## 12. Reconciliation comment policy
Post a reconciliation comment only if:
* exact PR-comment or issue-comment capability is proven
* the comment adds durable evidence (ancestor proof summary, recommended close
action, linked issue status)
* the comment will not duplicate an equivalent recent reconciliation comment
If comment capability is missing, record the intended comment in the final
handoff only.
## 13. PR close rules
Close a PR only if:
* already-landed proof passed in this session
* exact PR-close capability is proven
* PR is still open at mutation time (live re-fetch)
* head SHA still matches the proved candidate head SHA
If PR-close capability is missing, produce a recovery handoff with exact PR,
proof, and required capability. Do not loop forever re-blocking the reviewer
queue.
## 14. Issue close rules
Close a linked issue only if:
* exact issue-close capability is proven
* linked issue was fetched live
* issue resolution is justified by landed content and project policy
* issue is still open at mutation time
If issue-close capability is missing, report the gap in the handoff.
## 15. Missing capability behavior
If any required mutation capability is missing:
* do not improvise with review/merge tools
* do not ask the operator to bypass capability gates
* produce a recovery handoff listing exact missing capabilities
* include safe next action (profile switch, human close, or dedicated reconciler
profile)
## 16. Mutation classification
Use precise mutation categories in the final report:
* File edits by reconciler: (expect `none`)
* Worktree/index mutations:
* Git ref mutations: (`git fetch` belongs here, not read-only diagnostics)
* MCP/Gitea mutations:
* Reconciliation mutations: (PR comment, issue comment, PR close, issue close)
* External-state mutations:
* Read-only diagnostics:
Do not use legacy `Workspace mutations`.
## 17. Identity privacy rule
Report identity as `username / profile` (#305).
Do not disclose personal email in final reports unless explicitly required.
## 18. Precise final report
Include:
* canonical workflow source/version/hash
* authenticated identity/profile
* repo/project
* capability proof summary (separate lines for inspect, comment, PR close,
issue close)
* inventory pagination proof
* selected PR(s) with already-landed proof
* linked issue live status
* mutations performed or blocked
* missing capabilities
* confirmation that no normal review, approval, request-changes, or merge was
performed
## 19. Local artifact and report consistency rule
Do not create local walkthrough, notes, markdown, JSON, or report artifacts
during reconciliation unless explicitly required.
If any file is edited, report under `File edits by reconciler`.
Default: no repo file edits.
## 20. Forbidden unsupported claims unless proven
Do not claim:
* `already-landed`
* `PR closed`
* `issue closed`
* `pagination complete`
* `inventory complete`
* `all gates passed`
* `no unsafe mutation`
unless the corresponding proof is included.
## 21. Proof wording enforcement
Forbidden unless supported by current-session evidence:
* pagination complete
* final page
* no next page
* PR closed
* issue closed
* all gates passed
If proof comes from prior state, label as prior proof, not live proof.
## 22. Final self-check before output
Verify:
* no normal review/merge mutations occurred
* `git fetch` is under Git ref mutations if it occurred
* already-landed proof is present for each selected PR
* handoff uses reconciliation schema, not author/reviewer merge schema
* no contradiction between narrative report and controller handoff
## 23. Controller handoff schema
End every run with `Controller Handoff` per
[`schemas/reconcile-landed-final-report.md`](../schemas/reconcile-landed-final-report.md).
## 24. Stop conditions summary
Stop immediately and produce a recovery handoff if:
* canonical workflow cannot be loaded
* identity/profile/capability cannot be proven
* runtime context is blocked
* `infra_stop` appears
* inventory pagination cannot be proven and best-effort is not allowed
* already-landed proof cannot be completed
* required close capability is missing and mutation was attempted
* live PR/issue state contradicts proof
* any report contradiction cannot be resolved
Do not improvise around the gates.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,888 @@
---
task_mode: work-issue
canonical: true
final_report_schema: ../schemas/work-issue-final-report.md
---
# Work issue workflow (canonical)
**Task mode:** `work-issue`
This file is the canonical author/coder workflow for Gitea-Tools. Load it
before any issue implementation mutation. Final report schema:
[`schemas/work-issue-final-report.md`](../schemas/work-issue-final-report.md).
**Default task prompt:**
> Find the next eligible issue in this project, work on it only if all gates
> pass, and create a PR when complete.
Do not improvise around the gates. Follow project skills, MCP gates, and
workflow rules exactly.
This is an author/coder workflow. It is not a reviewer workflow.
---
## 0. Load the canonical workflow first
Before starting issue work, check whether the project provides a canonical work-on-issue workflow through a project skill, runbook, or MCP helper.
If available, load it first and report:
* workflow source
* workflow version, commit, or hash
* whether this prompt conflicts with the loaded workflow
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
## 1. Mode isolation
This run is `work-issue` mode only.
Do not:
* review PRs
* approve PRs
* request changes
* merge PRs
* close PRs unless the PR creation workflow explicitly does so through Gitea automation
* close unrelated issues
* mutate reviewer state
* perform reviewer-only actions
* create process-hardening issues unless explicitly authorized and the workflow switches to issue-creation mode
If the task requires review, merge, issue creation, or MCP repair mode, stop and produce a handoff for the correct workflow.
Do not mix modes in one run.
## 2. Start with live identity, profile, runtime, and capability checks
Prove:
* authenticated identity
* active author/coder profile
* repo/project
* runtime context
* exact capability for reading issues
* exact capability for claiming/locking issues, if available
* exact capability for branch creation, if handled through MCP
* exact capability for pushing branches, if applicable
* exact capability for creating PRs
* exact capability for commenting on issues or PRs, if needed
A nearby capability does not count.
Examples:
* `create_issue` does not authorize `issue_comment`
* `review_pr` does not authorize `merge_pr`
* `create_pr` does not authorize `merge_pr`
* `issue_comment` does not authorize `create_issue`
* `gitea.read` does not authorize issue claim, PR creation, or branch mutation
If capability cannot be proven, stop and produce a recovery handoff only.
## 3. Stop immediately on blocked infrastructure
If any of the following appears, stop immediately:
* `infra_stop`
* MCP reconnect failure
* stale capability state
* dirty control checkout
* dirty task worktree
* missing capability
* workspace mismatch
* stale target branch state
* broken canonical workflow loading
* failed required preflight
* capability resolver warning that says the current state may be unsafe
* stale or inconsistent runtime context
Do not continue issue selection, claiming, implementation, validation, commit, push, PR creation, cleanup, or handoff mutation.
Produce an executable recovery handoff only.
Blocked recovery handoffs must not include direct commit, push, or PR replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 4. Main checkout rule
The main project checkout must stay on `master`, `main`, or `dev`.
Do not do task work in the main checkout.
Do not edit files in the main checkout.
Do not run tests in the main checkout.
Do not commit from the main checkout.
Do not create PRs from the main checkout.
All task work must happen under the projects `branches/` directory.
No exceptions for small fixes, docs, tests, cleanup, conflict resolution, emergencies, or “just one file.”
If the main checkout is dirty before selection, stop and produce a recovery handoff.
If the main checkout becomes dirty during the run, stop and produce a recovery handoff unless the change is explicitly allowed by the canonical workflow.
## 5. No raw MCP repair during normal issue work
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
If MCP repair is required, stop issue work and produce a separate `CONTROL-CHECKOUT REPAIR MODE` handoff.
Do not mix MCP repair mode with work-on-issue mode.
Do not use successful repair as permission to resume the same issue workflow. After repair, rerun the full workflow from the beginning.
## 6. No background task tools
Do not use `schedule`, `manage_task`, background jobs, async waits, delayed task tools, or monitoring tasks during issue work.
Use direct commands and MCP tools only.
If a required action cannot complete synchronously, stop and produce a recovery handoff.
Long synchronous commands, such as a test suite, are allowed only if they are run directly and reported with exact command, working directory, and result.
Do not say “I will check later,” “I will monitor,” or “I will continue in the background.”
## 7. No local Gitea fallback during normal issue work
During normal author/coder workflows, do not read Gitea profile secret files.
Do not inspect or open files such as:
* `profiles.json`
* local token stores
* credential files
* local Gitea auth/profile config files
* `.env` files containing Gitea credentials
* keychain dumps
* token helper outputs
Do not run local Gitea helper scripts when MCP tools are available.
Use MCP tools for Gitea operations.
Local fallback is allowed only in explicit recovery mode when MCP is unavailable and identity/profile/capability can be independently proven.
If local fallback is used, report:
* why MCP was unavailable
* exact identity proof
* exact profile proof
* exact repo proof
* exact capability proof
* exact local command used
Do not use local fallback to bypass MCP gates.
## 8. Build a complete live issue inventory
List open issues according to the projects issue selection policy.
Follow pagination until the tool proves there are no more pages.
Do not assume inventory is complete.
Do not claim `next eligible issue`, `oldest eligible issue`, or complete issue inventory unless pagination is proven.
Pagination proof must not rely on assumed default API page size.
Inventory is complete only if one of the following is proven:
* the MCP response explicitly says there is no next page / `has_more=false` / final page
* the workflow traversed pages until an empty page or explicit final page was returned
* the tool response includes total-count or pagination metadata proving all relevant issues were returned
* the request explicitly set `page` / `limit` / `per_page`, and the response explicitly proves the server honored that page size and did not truncate results
Do not say “inventory complete” merely because the result count is less than an assumed default page size.
For each candidate issue, identify:
* issue number
* title
* labels
* status
* author/requester, if relevant
* assignee/owner, if any
* linked PRs, if any
* dependency/blocker labels, if any
* whether it appears already claimed
* whether it appears already implemented or superseded
* whether it is eligible under project rules
Final report must include pagination/final-page proof.
## 9. Issue selection rules
State the issue ordering policy before selecting an issue.
If the project uses oldest-first, explicitly sort or reason by issue number or created date.
Do not rely on API response order unless the tool proves that order matches the project policy.
Do not pick:
* already-claimed issues
* issues assigned to another active worker
* issues with an open PR already covering the work
* duplicate issues
* blocked issues
* dependency-blocked issues
* already implemented issues
* issues outside the current requested scope
* process-hardening issues unless this run was explicitly started for process-hardening work
* reviewer-only issues if this is author/coder mode
For every earlier issue skipped, report:
* issue number
* current status
* blocking category
* proof used
* whether there is an open PR
* whether there is an active claim
* reason it is not eligible
If eligibility cannot be proven, classify it as:
`ISSUE_ELIGIBILITY_UNVERIFIED`
Then stop or produce a recovery handoff according to project policy.
Do not select an issue based only on memory from a previous session.
## 10. Linked PR / duplicate active work proof
Before claiming or working on an issue, check whether there is already an open PR, branch, or active claim for that issue.
If an open PR already exists for the issue, do not implement duplicate work.
Classify the issue as:
`OPEN_PR_EXISTS`
and skip it only if project policy allows skipping.
If branch naming or PR title convention links issues to branches, search for matching branches or PRs.
Report:
* issue number
* linked/open PRs found
* matching branches found, if checked
* active claims found
* duplicate work status
Do not create another branch/PR for the same issue unless the project explicitly allows taking over or updating existing work and exact capability is proven.
## 11. Claim or lock the issue before implementation
Claim/lock the issue before implementation if the project provides a claim/lock mechanism.
If claim/lock requires a Gitea mutation, prove exact capability first.
If claim/lock fails, stop.
Do not implement unclaimed work.
If the claim/lock gates are broken, produce a recovery handoff.
Create a tooling issue only if this run is explicitly authorized to switch to issue-creation mode and exact `create_issue` capability is proven.
Report:
* claim mechanism used
* claim result
* claim timestamp, if available
* issue owner/assignee after claim, if available
## 12. Refresh stable branch before branch/worktree creation
Fetch the stable target branch from the remote before creating a task branch or worktree.
Do not rely on stale local `master`, `main`, or `dev`.
Record the fetched stable branch SHA.
If the stable branch cannot be fetched or verified, stop and produce a recovery handoff.
`git fetch`, `git remote update`, and any command that updates refs must be reported under `Git ref mutations`, not read-only diagnostics.
## 13. Branch and worktree ownership rule
Create a fresh session-owned worktree under `branches/`.
Prefer a branch name that includes the issue number, for example:
`feat/issue-<ISSUE_NUMBER>-short-description`
or:
`fix/issue-<ISSUE_NUMBER>-short-description`
Prefer a worktree path like:
`branches/issue-<ISSUE_NUMBER>-short-description`
Before any file edits, prove:
* project root
* current working directory
* main checkout branch
* stable branch
* stable branch SHA
* task branch name
* session-owned worktree path
* worktree path is inside `branches/`
* worktree is not the main checkout
* clean tracked state
* clean untracked state
* worktree HEAD/branch state
Do not reuse an existing worktree unless safe-reuse proof passes.
Safe-reuse proof must include:
* exact worktree path
* worktree is inside `branches/`
* worktree is not the main checkout
* worktree is not owned by another active task/session
* clean tracked state
* clean untracked state
* current branch/head before reset
* reset target SHA
* explicit project policy allowing reuse/reset
Do not run `git reset --hard`, `git clean`, checkout, or other destructive commands unless the worktree is session-owned or safe-reuse proof passes.
If safe-reuse proof cannot be produced, create a fresh session-owned worktree.
## 14. Implementation scope rule
Implement only what is required for the selected issue.
Do not perform opportunistic refactors.
Do not fix unrelated tests unless they are required for the selected issue and clearly documented.
Do not modify reviewer workflow files unless the selected issue explicitly requires workflow changes.
Do not modify Gitea profiles, MCP authorization, tokens, secrets, deployment config, production config, or credentials unless the selected issue explicitly requires it and exact capability/proof gates pass.
Do not introduce provenance markers, agent signatures, temporary files, debug dumps, or generated artifacts unless required.
If implementation uncovers a separate issue, note it in the final report or create a follow-up issue only if exact capability is proven and project policy allows it.
## 15. File edit rule
All edits must happen only inside the session-owned issue worktree.
Do not edit files in the main checkout.
Do not edit files in reviewer worktrees.
Do not edit unrelated worktrees.
Track every edited, created, deleted, or generated file.
If any file is edited, created, generated, or written, report it under `File edits by author`.
For each file write, report:
* exact path
* whether it was inside the repo
* whether it was tracked or untracked
* why it was created
* whether final `git status` was run after the write
Do not say `File edits by author: none` if any file write occurred.
Do not write files after the final clean-status check unless you rerun and report a new final clean-status check.
## 16. Validation rule
Run appropriate validation for the selected issue.
Validation may include:
* targeted tests
* full test suite
* compile checks
* lint checks
* type checks
* diff checks
* secret/provenance checks
* dangerous artifact checks
* project-specific validation
If validation cannot run, explain why and include the exact failure.
Do not hide failures.
Do not claim success if tests failed.
Do not skip required validation silently.
Do not bypass MCP gates.
Report every validation command with:
* exact command
* working directory
* exit code or pass/fail result
* summary count if available
* whether it was targeted, full-suite, compile, lint, diff, secret/provenance, or diagnostic validation
If using bare `pytest`, also report:
* `which pytest`
* `pytest --version`
* whether it resolves to the project venv
Prefer the project venv executable when available.
## 17. Baseline comparison rule
Do not run tests in the main checkout.
If the full suite fails and you need to prove failures are pre-existing, create a clean baseline worktree under `branches/`, such as:
`branches/baseline-master-issue-<ISSUE_NUMBER>`
Baseline comparison must include:
* baseline worktree path
* baseline target SHA
* task branch SHA
* exact command run on both worktrees
* baseline failures
* task branch failures
* proof the failure signatures match
* proof the baseline worktree was clean before and after validation
* proof the issue worktree was clean before and after validation
Do not claim “same as master” unless the clean baseline worktree proof is included.
Do not claim “full-suite failures are pre-existing” unless baseline proof is complete and the failure signatures match.
If full-suite failures differ or proof is incomplete, do not create a PR unless project policy explicitly allows PR creation with documented validation failures.
## 18. Pre-commit review
Before committing, review the actual diff.
Check:
* correctness
* tests
* scope
* security boundaries
* workflow rule compliance
* whether the implementation really satisfies the selected issue
* unrelated changes
* dangerous generated artifacts
* secrets
* provenance markers
* temporary agent files
* debug output
* formatting-only churn
* docs/tests consistency
Run:
* `git status`
* `git diff --stat`
* `git diff`
* project-required diff checks
Do not commit if unrelated or unsafe changes are present.
## 19. Commit rules
Commit only from the session-owned issue worktree.
Do not commit from the main checkout.
Commit only after implementation and required validation pass, unless project policy explicitly allows draft PRs with failing validation.
Commit message must reference the issue number.
Preferred format:
`fix: short summary (Closes #<ISSUE_NUMBER>)`
or:
`feat: short summary (Closes #<ISSUE_NUMBER>)`
Before commit, prove:
* worktree path
* branch name
* selected issue number
* staged files
* diff summary
* validation status
After commit, record:
* commit SHA
* commit message
* changed files
Do not amend, reset, rebase, squash, or force-push unless the project workflow explicitly allows it and the worktree is session-owned.
## 20. Push rules
Push only the session-owned task branch.
Do not push `master`, `main`, `dev`, tags, or unrelated branches.
Before push, prove:
* current branch
* upstream/remote target
* commit SHA being pushed
* selected issue number
* branch name matches the issue
After push, report:
* remote
* branch
* pushed commit SHA
* push result
If push fails, stop and produce a recovery handoff.
## 21. PR creation rules
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
Do not create a PR if:
* issue was not claimed/locked
* issue eligibility was unproven
* duplicate open PR exists
* task branch does not reference the issue
* implementation is incomplete
* validation failed without allowed exception
* worktree is dirty
* secrets/provenance/dangerous artifacts are present
* capability for PR creation is missing
* runtime context is blocked
* authenticated identity/profile changed unexpectedly
PR must reference or close the issue.
PR body must include:
* summary
* linked issue
* files changed
* validation commands and results
* risk
* exact worktree path
* branch name
* commit SHA
* known limitations, if any
Do not merge your own PR.
Do not approve your own PR.
Do not request changes on your own PR.
After PR creation, fetch or view the PR to verify:
* PR number
* PR URL
* PR title
* base branch
* head branch
* linked issue
* head SHA
* open status
## 22. Cleanup rules
Clean only session-owned temporary/baseline worktrees if the project workflow explicitly allows cleanup.
Do not delete unrelated branches/worktrees.
Do not delete the task worktree if the project expects it to remain for handoff unless policy says cleanup is allowed after PR creation.
Do not update the main checkout unless the canonical workflow explicitly allows it.
Any cleanup is a mutation and must be reported.
## 23. Recovery handoff rules
If blocked, produce a recovery handoff with:
* exact blocker
* failed tool/function, if any
* repo/project
* selected issue, if one was safely selected
* eligibility class
* claim/lock state
* branch name, if created
* worktree path, if created
* stable branch and stable branch SHA, if known
* files changed, if any
* validation state
* commit SHA, if committed
* PR number/URL, if created
* exact state reached before stopping
* safe next action
* statement that no unsafe mutation was attempted
Blocked handoffs must not include direct commit, push, or PR replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 24. Final report must be precise
Include:
* canonical workflow source/version/hash, if available
* authenticated identity/profile
* repo/project
* capability proof summary
* issue inventory proof, including pagination/final-page proof
* issue ordering policy used
* selected issue number/title
* eligibility class
* skipped earlier issues and proof, if any
* duplicate active work proof
* claim/lock result
* stable branch and stable branch SHA
* branch name
* worktree path
* worktree inside `branches/`: true/false
* worktree branch/HEAD state
* worktree dirty before implementation: true/false
* files changed
* validation commands and results
* baseline comparison result, if used
* pre-commit diff review result
* commit SHA and commit message, if committed
* push result, if pushed
* PR number and URL, if created
* PR verification result, if created
* cleanup result
* blockers, if stopped
* confirmation that the main checkout was not used for task work
If the report and actual tool/command log disagree, fix the report before final output.
## 25. Final report must distinguish mutation types
Do not use the legacy field `Workspace mutations`.
Use only precise categories:
* File edits by author:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Branch mutations:
* Commit mutations:
* Push mutations:
* PR mutations:
* Cleanup mutations:
* External-state mutations:
* Read-only diagnostics:
`git fetch`, `git remote update`, and any command that updates refs must be listed under `Git ref mutations`, not read-only diagnostics.
If `git reset --hard`, checkout, clean, worktree add/remove, merge simulation, merge abort, or similar commands occurred, report them under `Worktree/index mutations`.
Use precise wording:
* `File edits by author: none`
* `Worktree/index mutations: ...`
* `Git ref mutations: ...`
* `MCP/Gitea mutations: ...`
Do not collapse issue, branch, commit, push, PR, cleanup, or external-state mutations into vague wording.
## 26. Forbidden final-report claims unless proven
Do not claim:
* `next eligible issue`
* `oldest eligible issue`
* `issue claimed`
* `no duplicate work`
* `no open PR`
* `worktree clean`
* `validation passed`
* `same as master`
* `full-suite failures are pre-existing`
* `committed`
* `pushed`
* `PR created`
* `issue closed`
* `main checkout untouched`
* `no file edits`
* `no unsafe mutation`
* `all gates passed`
* `target branch up to date`
unless the corresponding proof is included.
If anything blocks safe work or PR creation, stop immediately and produce an executable recovery handoff.
Do not improvise around the gates.
## 27. Proof wording enforcement
The following phrases are forbidden unless directly supported by current-session evidence:
* next eligible issue
* oldest eligible issue
* inventory complete
* no duplicate work
* issue claimed
* worktree clean
* validation passed
* same as master
* full-suite failures are pre-existing
* committed
* pushed
* PR created
* issue closed
* target branch up to date
* all gates passed
* no unsafe mutation
* no file edits
If the proof comes from prior state rather than a command/tool run in the current session, label it as prior proof, not live proof.
If a tool call was rejected, confirmation-gated, dry-run-only, or no-op, report it separately from performed mutations.
## 28. Final self-check before output
Before final output, check the report for contradictions.
Verify:
* if any file was edited, `File edits by author` is not `none`
* if any worktree was added/removed, `Worktree/index mutations` lists it
* if any fetch happened, `Git ref mutations` lists it
* if any issue was claimed/commented/updated, `Issue mutations` lists it
* if any branch was created, `Branch mutations` lists it
* if any commit was created, `Commit mutations` lists it
* if any push occurred, `Push mutations` lists it
* if any PR was created, `PR mutations` lists it
* if any cleanup happened, `Cleanup mutations` lists it
* if any issue/PR external state changed, `External-state mutations` lists it
* if pagination is claimed complete, final-page proof is present
* if same-as-master is claimed, baseline proof is complete
* if selected issue is claimed next eligible, every earlier issue has proof-backed skip reasoning
* if PR created is claimed, PR verification proof is present
* if main checkout untouched is claimed, main checkout status proof is present
If any contradiction exists, fix the final report before output.
## 29. Controller handoff schema
End every run with a controller handoff using this schema.
Do not omit fields. Use `none` or `not verified in this session` where appropriate.
Controller Handoff:
* Task:
* Repo:
* Role:
* Identity:
* Active profile:
* Runtime context:
* Selected issue:
* Eligibility class:
* Issue ordering policy:
* Issue inventory pagination proof:
* Earlier issues skipped:
* Duplicate active work proof:
* Claim/lock state:
* Stable branch:
* Stable branch SHA:
* Branch name:
* Worktree path:
* Worktree inside branches:
* Worktree branch/HEAD state:
* Worktree dirty before implementation:
* Files changed:
* Validation:
* Baseline comparison:
* Commit SHA:
* Push result:
* PR number:
* PR URL:
* PR verification:
* Main checkout branch:
* Main checkout dirty state:
* Main checkout used for task work:
* File edits by author:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Branch mutations:
* Commit mutations:
* Push mutations:
* PR mutations:
* Cleanup mutations:
* External-state mutations:
* Read-only diagnostics:
* Blockers:
* Current status:
* Safe next action:
* Safety statement:
## 30. Stop conditions summary
Stop immediately and produce a recovery handoff if:
* canonical workflow is required but cannot be loaded
* identity/profile/capability cannot be proven
* runtime context is blocked
* infra stop appears
* MCP reconnect fails
* capability state is stale
* issue inventory pagination cannot be proven
* issue ordering cannot be proven
* issue eligibility cannot be proven
* duplicate active work cannot be checked
* selected issue is already claimed by another worker
* selected issue already has an open PR
* claim/lock fails
* stable branch cannot be fetched
* task worktree cannot be created safely
* task worktree is dirty before implementation
* validation cannot run
* validation fails without allowed exception
* baseline comparison is required but incomplete
* diff review finds unrelated or unsafe changes
* commit fails
* push fails
* PR creation fails
* PR verification fails
* any report contradiction cannot be resolved
Blocked handoffs must not include direct commit, push, or PR replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
Do not improvise around the gates.
+166
View File
@@ -0,0 +1,166 @@
"""Fail-closed subagent delegation gates (#266).
Subagents can bypass or lose context for worktree, capability, mutation,
retry, and reporting rules. These helpers make delegation an explicit,
provable decision instead of a default: deterministic write tasks stay
inline unless the parent session records why a subagent is needed and
hands the subagent the full gate context it must operate under.
Like ``author_proofs``/``review_proofs``, the helpers are pure (no git,
no API calls): the parent workflow gathers the facts and passes them in,
so the same logic works from prompts, harness assertions, and tests.
Nothing here weakens the review/merge/permission gates a delegated
subagent is subject to the same gates as its parent.
"""
# AC1: deterministic write workflows a subagent must never run by default.
DETERMINISTIC_WRITE_TASKS = frozenset({
"claim_issue",
"create_branch",
"edit_code",
"commit",
"push_branch",
"create_pr",
"review_pr",
"merge_pr",
"cleanup_branch",
"close_issue",
})
# Read-only delegation that needs no explicit authorization.
READ_ONLY_TASKS = frozenset({
"read_files",
"code_search",
"inventory_prs",
"summarize_issue",
"explore_codebase",
})
# AC3: context a subagent must inherit from the parent session before any
# authorized write delegation may proceed.
REQUIRED_INHERITED_CONTEXT = (
"issue_lock",
"branch",
"worktree_path",
"identity_profile",
"allowed_tool_class",
"command_deny_list",
"validation_ledger_requirement",
"final_report_schema",
)
# AC4: proof fields a subagent final report must carry — the same fields a
# parent workflow's final report requires.
REQUIRED_SUBAGENT_REPORT_FIELDS = (
"identity_profile",
"worktree_path",
"branch",
"changed_files",
"validation_results",
"workspace_mutations",
)
def _clean(value):
return (value or "").strip() if isinstance(value, str) else value
def _classify_task(task_type):
task = _clean(task_type)
if not task:
return "", "unknown"
if task in DETERMINISTIC_WRITE_TASKS:
return task, "deterministic_write"
if task in READ_ONLY_TASKS:
return task, "read_only"
return task, "unknown"
def _missing_context_fields(inherited_context):
context = inherited_context or {}
missing = []
for field in REQUIRED_INHERITED_CONTEXT:
value = context.get(field)
if not isinstance(value, str) or not value.strip():
missing.append(field)
return missing
def assess_subagent_delegation(task_type, *, explicitly_allowed=False,
justification=None, inherited_context=None):
"""Decide whether delegating *task_type* to a subagent may proceed.
Fail closed: unknown tasks are blocked; deterministic write tasks are
blocked unless explicitly allowed (AC1) with a recorded justification
(AC2) and the full inherited gate context (AC3). Read-only delegation
is allowed without explicit authorization.
Returns {'block', 'allowed', 'task_type', 'task_class', 'reasons',
'missing_context'}.
"""
task, task_class = _classify_task(task_type)
reasons = []
missing_context = []
if task_class == "unknown":
reasons.append(
f"task type '{task}' is not a recognized delegation class; "
"run it inline in the parent session (fail closed)"
)
elif task_class == "deterministic_write":
if not explicitly_allowed:
reasons.append(
f"deterministic write task '{task}' must run inline unless "
"subagent use is explicitly allowed by the parent session"
)
if not _clean(justification):
reasons.append(
"no recorded justification for why a subagent is needed; "
"the parent session must record one before delegating"
)
missing_context = _missing_context_fields(inherited_context)
if missing_context:
reasons.append(
"subagent would not inherit required gate context: "
+ ", ".join(missing_context)
)
block = bool(reasons)
return {
"block": block,
"allowed": not block,
"task_type": task,
"task_class": task_class,
"reasons": reasons,
"missing_context": missing_context,
}
def validate_subagent_report(report_fields):
"""AC4: accept subagent output only with the parent-grade proof fields.
*report_fields* maps field name -> reported value. Missing or blank
proof fields make the report invalid (fail closed).
Returns {'valid', 'block', 'reasons', 'missing_fields'}.
"""
reasons = []
report = report_fields or {}
missing = []
for field in REQUIRED_SUBAGENT_REPORT_FIELDS:
value = report.get(field)
if not isinstance(value, str) or not value.strip():
missing.append(field)
if missing:
reasons.append(
"subagent final report missing required proof fields: "
+ ", ".join(missing)
)
valid = not reasons
return {
"valid": valid,
"block": not valid,
"reasons": reasons,
"missing_fields": missing,
}
+18 -1
View File
@@ -28,6 +28,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.issue.comment",
"role": "author",
},
"lock_issue": {
"permission": "gitea.issue.comment",
"role": "author",
},
"set_issue_labels": {
"permission": "gitea.issue.comment",
"role": "author",
@@ -80,6 +84,18 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.branch.delete",
"role": "author",
},
"commit_files": {
"permission": "gitea.repo.commit",
"role": "author",
},
"gitea_commit_files": {
"permission": "gitea.repo.commit",
"role": "author",
},
"reconcile_merged_cleanups": {
"permission": "gitea.read",
"role": "author",
},
}
# Issue-mutating MCP tools and their resolver task keys.
@@ -89,6 +105,7 @@ ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
"gitea_create_issue_comment": "comment_issue",
"gitea_mark_issue": "mark_issue",
"gitea_set_issue_labels": "set_issue_labels",
"gitea_commit_files": "commit_files",
}
@@ -110,4 +127,4 @@ def required_role(task: str) -> str:
def tool_required_permission(tool_name: str) -> str:
"""Return the operation an issue-mutating tool must gate on."""
return required_permission(ISSUE_MUTATION_TOOL_TASKS[tool_name])
return required_permission(ISSUE_MUTATION_TOOL_TASKS[tool_name])
+87
View File
@@ -0,0 +1,87 @@
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import agent_temp_artifacts
import mcp_server
class TestAgentTempArtifactPatterns(unittest.TestCase):
def test_detects_root_level_encode_emit_inline(self):
porcelain = (
"?? _encode_commit_payload.py\n"
"?? _emit_payload.py\n"
"?? _inline_b64.py\n"
)
self.assertEqual(
agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(porcelain),
["_emit_payload.py", "_encode_commit_payload.py", "_inline_b64.py"],
)
def test_ignores_nested_and_unrelated_untracked(self):
porcelain = (
"?? scripts/_encode_x.py\n"
"?? README-draft.md\n"
" M task_capability_map.py\n"
)
self.assertEqual(
agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(porcelain),
[],
)
class TestPreflightWarnings(unittest.TestCase):
def setUp(self):
self._snapshot = (
mcp_server._preflight_whoami_called,
mcp_server._preflight_capability_called,
)
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
def tearDown(self):
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
self._snapshot
)
@patch(
"mcp_server._get_workspace_porcelain",
return_value="?? _encode_commit_payload.py\n",
)
def test_assess_preflight_surfaces_warning_not_block(self, _porcelain):
status = mcp_server.assess_preflight_status()
self.assertTrue(status["preflight_ready"])
self.assertEqual(status["preflight_block_reasons"], [])
self.assertEqual(len(status["preflight_warnings"]), 1)
self.assertIn("_encode_commit_payload.py", status["preflight_warnings"][0])
class TestIssueLockArtifactWarning(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server._auth", return_value="token x")
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
@patch("mcp_server.ISSUE_LOCK_FILE", new_callable=lambda: tempfile.mktemp())
@patch("issue_lock_worktree.read_worktree_git_state")
def test_lock_success_includes_artifact_warning(self, mock_state, _lock_file, *_mocks):
mock_state.return_value = {
"current_branch": "master",
"porcelain_status": "?? _emit_payload.py\n",
}
result = mcp_server.gitea_lock_issue(
issue_number=261,
branch_name="docs/issue-261-agent-artifact-cleanup",
remote="prgs",
)
self.assertTrue(result["success"])
self.assertIn("warnings", result)
self.assertIn("_emit_payload.py", result["warnings"][0])
if __name__ == "__main__":
unittest.main()
+8 -2
View File
@@ -293,9 +293,12 @@ class TestGatedToolAudit(_AuditWiringBase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_merge_success_audited(self, _auth, mock_api):
# user, pr, merge POST, readback — no extra identity call (uses result).
# user, pr, feedback pr+reviews, merge POST, readback.
mock_api.side_effect = [
{"login": "merger-bot"}, self._pr("author-bot"),
self._pr("author-bot"),
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
{}, {"merged_commit_sha": "c1"},
]
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
@@ -332,7 +335,10 @@ class TestGatedToolAudit(_AuditWiringBase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_submit_review_success_audited(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 7},
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 7, "state": "APPROVED"},
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
]
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
GITEA_ALLOWED_OPERATIONS="read,review,approve")
+43 -2
View File
@@ -29,8 +29,8 @@ CONFIG = {
"username": "jcwalker3",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.pr.create",
"gitea.branch.push",
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
"gitea.pr.create", "gitea.branch.push",
],
"forbidden_operations": [
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
@@ -97,6 +97,34 @@ class TestCapabilityStopTerminal(unittest.TestCase):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_list_prs(remote="prgs")
self.assertIn("Cannot perform reviewer task", str(ctx.exception))
self.assertIn("review_pr", str(ctx.exception))
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_list_prs_allowed_after_author_task_clears_stale_denial(
self, _auth, _api, mock_fetch,
):
mock_fetch.return_value = ([], {
"page": 1, "per_page": 50, "returned_count": 0,
"has_more": False, "next_page": None, "is_final_page": True,
})
with patch.dict(os.environ, self._env()):
denied = mcp_server.gitea_resolve_task_capability(
task="review_pr", remote="prgs"
)
self.assertTrue(denied["stop_required"])
self.assertTrue(capability_stop_terminal.is_active())
cleared = mcp_server.gitea_resolve_task_capability(
task="claim_issue", remote="prgs"
)
self.assertTrue(cleared["allowed_in_current_session"])
self.assertTrue(cleared.get("cleared_stale_denial"))
self.assertFalse(capability_stop_terminal.is_active())
prs = mcp_server.gitea_list_prs(remote="prgs")
self.assertEqual(prs["prs"], [])
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
@@ -148,6 +176,19 @@ class TestCapabilityStopTerminal(unittest.TestCase):
)
self.assertFalse(result["pure"])
def test_empty_queue_with_parsed_trusted_status_passes_gate_check(self):
report = (
"Cannot perform reviewer task under current profile. "
"No reviewer mutations performed.\n"
"Repository: Scaled-Tech-Consulting/Gitea-Tools\n"
"pr_inventory_trust_gate.status: trusted_empty\n"
"pr_inventory_trust_gate.corroborated: true\n"
"Inventory profile: prgs-reviewer\n"
"No open PRs in queue."
)
result = assess_capability_stop_terminal_report(report)
self.assertTrue(result["pure"])
def test_pure_terminal_report_passes(self):
report = (
"Cannot perform reviewer task under current profile. "
+199
View File
@@ -0,0 +1,199 @@
"""Tests for commit_files task capability resolver mapping (#262)."""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import task_capability_map
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"commit-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
"forbidden_operations": ["gitea.pr.create"],
"execution_profile": "commit-author",
},
"pr-only-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "pr-author",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.pr.create"],
"forbidden_operations": ["gitea.repo.commit"],
"execution_profile": "pr-only-author",
},
"reviewer-profile": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "reviewer-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.merge"],
"forbidden_operations": [
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
],
"execution_profile": "reviewer-profile",
},
},
"rules": {"allow_runtime_switching": False},
}
class CommitFilesCapabilityBase(unittest.TestCase):
def setUp(self):
self._preflight_snapshot = (
mcp_server._preflight_whoami_called,
mcp_server._preflight_capability_called,
)
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
self._preflight_snapshot
)
self._dir.cleanup()
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
}
class TestCommitFilesResolver(CommitFilesCapabilityBase):
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_resolves_to_repo_commit(self, _auth, _api):
with patch.dict(os.environ, self._env("commit-author"), clear=True):
for task in ("commit_files", "gitea_commit_files"):
with self.subTest(task=task):
res = mcp_server.gitea_resolve_task_capability(
task=task, remote="prgs")
self.assertEqual(res["required_operation_permission"],
"gitea.repo.commit")
self.assertEqual(res["required_role_kind"], "author")
self.assertTrue(res["allowed_in_current_session"])
@patch("mcp_server.api_request", return_value={"login": "pr-author"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_create_pr_does_not_satisfy_commit_files(self, _auth, _api):
with patch.dict(os.environ, self._env("pr-only-author"), clear=True):
commit_res = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs")
pr_res = mcp_server.gitea_resolve_task_capability(
task="create_pr", remote="prgs")
self.assertFalse(commit_res["allowed_in_current_session"])
self.assertTrue(pr_res["allowed_in_current_session"])
self.assertEqual(commit_res["required_operation_permission"],
"gitea.repo.commit")
self.assertEqual(pr_res["required_operation_permission"],
"gitea.pr.create")
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_reviewer_denied_commit_files(self, _auth, _api):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
res = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs")
self.assertFalse(res["allowed_in_current_session"])
self.assertEqual(res["required_operation_permission"],
"gitea.repo.commit")
self.assertTrue(res["different_mcp_namespace_required"])
class TestCommitFilesToolGate(CommitFilesCapabilityBase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_allowed_author_proceeds(self, _auth, mock_api, _role):
mock_api.return_value = {
"commit": {"sha": "abc"},
"branch": {"name": "feat/x"},
}
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with patch.dict(os.environ, self._env("commit-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "a.txt", "content": "YQ=="}],
message="test",
new_branch="feat/x",
remote="prgs",
)
self.assertTrue(res["success"])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_reviewer_blocked_with_permission_report(self, _auth, _role):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "a.txt", "content": "YQ=="}],
message="test",
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res.get("performed", True))
self.assertIn("permission_report", res)
self.assertEqual(res["permission_report"]["missing_permission"],
"gitea.repo.commit")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
def test_preflight_blocks_before_api(self, _role):
env = {**self._env("commit-author"), "GITEA_TEST_FORCE_DIRTY": "1"}
with patch.dict(os.environ, env, clear=True):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "a.txt", "content": "YQ=="}],
message="test",
remote="prgs",
)
self.assertIn("gitea_whoami", str(ctx.exception))
class TestCommitFilesMapAlignment(unittest.TestCase):
def test_tool_gate_matches_resolver(self):
self.assertEqual(
task_capability_map.tool_required_permission("gitea_commit_files"),
task_capability_map.required_permission("commit_files"),
)
if __name__ == "__main__":
unittest.main()
+251
View File
@@ -0,0 +1,251 @@
"""Regression tests: gitea_commit_files tool gates match resolver and whoami verification.
Covers Issue #262 requirements.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import task_capability_map
import gitea_config
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"full-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.repo.commit"
],
"forbidden_operations": [],
"execution_profile": "full-author",
},
"reviewer-no-commit": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "reviewer-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": [
"gitea.read", "gitea.pr.review", "gitea.pr.approve"
],
"forbidden_operations": [
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
],
"execution_profile": "reviewer-no-commit",
},
},
"rules": {"allow_runtime_switching": False},
}
class TestCommitFilesGate(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir.cleanup()
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
}
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_allowed_author_proceeds(self, _auth, mock_api, _role):
mock_api.return_value = {
"commit": {"sha": "abc123commit"},
"branch": {"name": "some-branch"},
}
with patch.dict(os.environ, self._env("full-author"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs"
)
self.assertTrue(resolve["allowed_in_current_session"])
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertTrue(res["success"])
self.assertEqual(res["commit"], "abc123commit")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_denied_reviewer_blocked(self, _auth, mock_api, _role):
mock_api.return_value = {"login": "reviewer-user"}
with patch.dict(os.environ, self._env("reviewer-no-commit"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs"
)
self.assertFalse(resolve["allowed_in_current_session"])
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res.get("performed", True))
self.assertIn("permission_report", res)
self.assertEqual(
res["permission_report"]["missing_permission"], "gitea.repo.commit"
)
post_calls = [c for c in mock_api.call_args_list if len(c.args) > 0 and c.args[0] == "POST"]
self.assertFalse(post_calls)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_unknown_profile_fails_closed(self, _auth, mock_api, _role):
mock_api.return_value = {"login": "reviewer-user"}
with patch.dict(os.environ, self._env("non-existent"), clear=True):
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res.get("performed", True))
post_calls = [c for c in mock_api.call_args_list if len(c.args) > 0 and c.args[0] == "POST"]
self.assertFalse(post_calls)
class TestPreflightCommitFilesGate(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self.orig_whoami_called = mcp_server._preflight_whoami_called
self.orig_capability_called = mcp_server._preflight_capability_called
self.orig_whoami_violation = mcp_server._preflight_whoami_violation
self.orig_capability_violation = mcp_server._preflight_capability_violation
self.orig_resolved_role = mcp_server._preflight_resolved_role
self.orig_process_start = mcp_server._process_start_porcelain
self.orig_whoami_baseline = mcp_server._preflight_whoami_baseline_porcelain
self.orig_capability_baseline = mcp_server._preflight_capability_baseline_porcelain
self.orig_whoami_files = mcp_server._preflight_whoami_violation_files
self.orig_capability_files = mcp_server._preflight_capability_violation_files
self.orig_reviewer_files = mcp_server._preflight_reviewer_violation_files
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
mcp_server._preflight_whoami_violation = False
mcp_server._preflight_capability_violation = False
mcp_server._preflight_resolved_role = None
mcp_server._process_start_porcelain = ""
mcp_server._preflight_whoami_baseline_porcelain = None
mcp_server._preflight_capability_baseline_porcelain = None
mcp_server._preflight_whoami_violation_files = []
mcp_server._preflight_capability_violation_files = []
mcp_server._preflight_reviewer_violation_files = []
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called = self.orig_whoami_called
mcp_server._preflight_capability_called = self.orig_capability_called
mcp_server._preflight_whoami_violation = self.orig_whoami_violation
mcp_server._preflight_capability_violation = self.orig_capability_violation
mcp_server._preflight_resolved_role = self.orig_resolved_role
mcp_server._process_start_porcelain = self.orig_process_start
mcp_server._preflight_whoami_baseline_porcelain = self.orig_whoami_baseline
mcp_server._preflight_capability_baseline_porcelain = self.orig_capability_baseline
mcp_server._preflight_whoami_violation_files = self.orig_whoami_files
mcp_server._preflight_capability_violation_files = self.orig_capability_files
mcp_server._preflight_reviewer_violation_files = self.orig_reviewer_files
self._dir.cleanup()
os.environ.pop("GITEA_TEST_FORCE_DIRTY", None)
os.environ.pop("GITEA_TEST_PORCELAIN", None)
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
}
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_preflight_not_called_blocks_commit(self, _auth, mock_api):
mock_api.return_value = {"login": "author-user"}
with patch.dict(os.environ, self._env("full-author"), clear=True):
os.environ["GITEA_TEST_PORCELAIN"] = ""
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertIn("Identity (gitea_whoami) has not been verified", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_dirty_workspace_before_whoami_blocks_commit(self, _auth, mock_api):
mock_api.return_value = {"login": "author-user"}
with patch.dict(os.environ, self._env("full-author"), clear=True):
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertIn("Workspace file edits occurred before gitea_whoami verification", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+251
View File
@@ -0,0 +1,251 @@
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import gitea_config
import mcp_server
import task_capability_map
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"full-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read",
"gitea.repo.commit",
"gitea.pr.create",
"gitea.branch.push",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.review",
],
"execution_profile": "full-author",
},
},
}
class TestCommitPayloads(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
# Setup temp directories and lock files
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
self.locked_worktree_dir = tempfile.TemporaryDirectory()
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
self.lock_file_path = "/tmp/gitea_issue_lock.json"
self.lock_data = {
"issue_number": 263,
"branch_name": "feat/issue-263-native-commit-payloads",
"remote": "prgs",
"org": "Example-Org",
"repo": "Example-Repo",
"worktree_path": self.locked_worktree_path,
}
with open(self.lock_file_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(self.lock_data))
# Reset preflight status to bypass/pass verification in tests
self.orig_whoami_called = mcp_server._preflight_whoami_called
self.orig_capability_called = mcp_server._preflight_capability_called
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called = self.orig_whoami_called
mcp_server._preflight_capability_called = self.orig_capability_called
self._dir.cleanup()
self.locked_worktree_dir.cleanup()
if os.path.exists(self.lock_file_path):
os.remove(self.lock_file_path)
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TEST_PORCELAIN": "",
}
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_content_plain(self, _auth, mock_api):
mock_api.return_value = {
"commit": {"sha": "sha-123"},
"branch": {"name": "feat/issue-263-native-commit-payloads"}
}
with patch.dict(os.environ, self._env("full-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hello.txt",
"content_plain": "Hello World!",
}
],
message="Add hello.txt",
remote="prgs",
)
print("DEBUG RES IS:", res)
self.assertTrue(res["success"])
self.assertEqual(res["commit"], "sha-123")
self.assertEqual(res["content_source_proof"][0]["source"], "inline_plain")
# Verify posted base64 content
post_args = mock_api.call_args[0]
payload = post_args[3]
self.assertEqual(payload["files"][0]["content"], "SGVsbG8gV29ybGQh")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_workspace_path(self, _auth, mock_api):
mock_api.return_value = {
"commit": {"sha": "sha-456"},
"branch": {"name": "feat/issue-263-native-commit-payloads"}
}
# Create a workspace file
file_relative = "src/code.py"
file_abs = os.path.join(self.locked_worktree_path, file_relative)
os.makedirs(os.path.dirname(file_abs), exist_ok=True)
with open(file_abs, "wb") as fh:
fh.write(b"print('hello')")
with patch.dict(os.environ, self._env("full-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "src/code.py",
"workspace_path": file_relative,
}
],
message="Add code.py",
remote="prgs",
)
self.assertTrue(res["success"])
self.assertEqual(res["content_source_proof"][0]["source"], "workspace_path")
post_args = mock_api.call_args[0]
payload = post_args[3]
# "print('hello')" in base64 is cHJpbnQoJ2hlbGxvJyk=
self.assertEqual(payload["files"][0]["content"], "cHJpbnQoJ2hlbGxvJyk=")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_local_path(self, _auth, mock_api):
mock_api.return_value = {
"commit": {"sha": "sha-789"},
"branch": {"name": "feat/issue-263-native-commit-payloads"}
}
file_abs = os.path.join(self.locked_worktree_path, "local.bin")
with open(file_abs, "wb") as fh:
fh.write(b"\x00\x01\x02\x03\xff")
with patch.dict(os.environ, self._env("full-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "local.bin",
"local_path": file_abs,
}
],
message="Add local.bin",
remote="prgs",
)
self.assertTrue(res["success"])
self.assertEqual(res["content_source_proof"][0]["source"], "local_path")
post_args = mock_api.call_args[0]
payload = post_args[3]
# b"\x00\x01\x02\x03\xff" in base64 is AAECA/8=
self.assertEqual(payload["files"][0]["content"], "AAECA/8=")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_traversal_blocked(self, _auth, mock_api):
# Remove active lock file to ensure it fails on traversal/invalid locks
os.remove(self.lock_file_path)
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hack.txt",
"workspace_path": "any.txt",
}
],
message="Add hack",
remote="prgs",
)
self.assertIn("Issue lock is missing", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(ValueError) as ctx:
mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hack.txt",
"workspace_path": "../outside.txt",
}
],
message="Add hack",
remote="prgs",
)
self.assertIn("falls outside of locked worktree", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(ValueError) as ctx:
mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hello.txt",
"content_plain": "Hello!",
"content": "SGVsbG8=",
}
],
message="Add hello",
remote="prgs",
)
self.assertIn("Multiple content sources specified", str(ctx.exception))
+59 -1
View File
@@ -1,4 +1,4 @@
"""Documentation checks for external Jenkins/GlitchTip MCP registration (#151)."""
"""Documentation checks for external Jenkins/GlitchTip MCP registration (#151/#152)."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -52,11 +52,36 @@ def test_registration_doc_preserves_boundaries():
"Do not add Jenkins or GlitchTip credentials to the Gitea MCP server",
"do not add Gitea write credentials to the GlitchTip server",
"must not expose build trigger tools",
"jenkins-write-mcp",
"jenkins_mcp.write_server",
"remains read-only",
):
assert phrase in text
def test_registration_doc_pins_glitchtip_filing_boundary():
text = " ".join(_doc_text().split())
for phrase in (
"GlitchTip-to-Gitea filing is a separate library-only orchestrator in "
"`mcp-control-plane`",
"real GlitchTip read path",
"dedup before any Gitea create action",
"create/link/skip decisions",
"fail closed on mutation-audit failure before any Gitea create, link, "
"or comment mutation",
"must use a separate write-boundary server/profile",
"must not be exposed by `glitchtip-mcp`",
):
assert phrase in text
def test_registration_doc_separates_jenkins_trigger_from_read_surface():
text = _doc_text()
assert "jenkins_trigger_build" in text
assert "must **not** appear on `jenkins-mcp`" in text
assert "not" in text.lower() and "registered by default" in text.lower()
def test_registration_doc_has_no_secret_material_or_live_urls():
text = _doc_text()
for marker in (
@@ -80,3 +105,36 @@ def test_related_docs_link_registration_doc():
text = (REPO_ROOT / name).read_text(encoding="utf-8")
assert "mcp-client-registration.md" in text, (
f"{name} does not link docs/mcp-client-registration.md")
def test_related_docs_keep_jenkins_trigger_off_read_surface():
for name in (
"docs/safety-model.md",
"docs/architecture/jenkins-readonly-build-status-design.md",
):
text = (REPO_ROOT / name).read_text(encoding="utf-8")
assert "jenkins-write-mcp" in text
assert "jenkins_mcp.write_server" in text
assert "jenkins-mcp" in text
def test_glitchtip_filing_contract_doc_covers_issue_153_acceptance():
text = (
REPO_ROOT / "docs" / "architecture" /
"glitchtip-to-gitea-workflow-design.md"
).read_text(encoding="utf-8")
flattened = " ".join(text.split())
for phrase in (
"**Status:** Contract for the library-only implementation in "
"`Scaled-Tech-Consulting/mcp-control-plane` (#57)",
"**Issue:** #153",
"not exposed through `glitchtip-mcp`",
"real GlitchTip read path",
"must not use mocked GlitchTip issue data",
"separate write-boundary server/profile",
"fail-closed mutation audit before create/link/comment actions",
"Deduplication before create",
"Create, link, and skip decisions must be represented in tests",
"Mutation audit fails closed",
):
assert phrase in flattened
+273
View File
@@ -0,0 +1,273 @@
"""Tests for composable final-report validator (#327)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from final_report_validator import ( # noqa: E402
FINAL_REPORT_TASK_KINDS,
assess_final_report_validator,
validator_finding,
)
def _review_handoff(**overrides):
lines = [
"## Controller Handoff",
"",
"- Task: review PR #203",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: reviewer",
"- Identity: sysadmin / prgs-reviewer",
"- Issue/PR: #182 / PR #203",
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Files changed: review_proofs.py",
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Mutations: review only",
"- Workspace mutations: none",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
"- Git ref mutations: git fetch prgs master",
"- MCP/Gitea mutations: review submitted",
"- Current status: PR open",
"- Blockers: none",
"- Next: await author",
"- Safety: no self-review; no self-merge",
"- Selected PR: #203",
"- Reviewer eligibility: eligible",
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Worktree path: branches/review-203",
"- Worktree dirty: clean",
"- Scratch worktree used: yes (branches/review-203)",
"- Unrelated local mutations: none",
"- Review decision: request_changes",
"- Merge result: not attempted",
"- Linked issue: #182",
"- Linked issue status: open",
"- Cleanup status: none",
]
text = "\n".join(lines)
for key, value in overrides.items():
text = text.replace(f"- {key}:", f"- {key}: {value}")
return text
def _reconcile_handoff(**overrides):
lines = [
"## Controller Handoff",
"",
"- Task: reconcile already-landed PR #99",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: reconciler",
"- Identity: sysadmin / prgs-author",
"- Selected PR: #99",
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
"- Linked issue: #98",
"- Linked issue live status: not verified in this session",
"- File edits by reconciler: none",
"- Git ref mutations: git fetch prgs master",
"- Reconciliation mutations: PR comment posted",
"- Current status: reconciliation complete",
"- Safe next action: none",
"- No review/merge confirmation: confirmed",
]
text = "\n".join(lines)
for key, value in overrides.items():
if f"- {key}:" in text:
text = text.replace(f"- {key}:", f"- {key}: {value}")
return text
class TestValidatorFinding(unittest.TestCase):
def test_finding_shape(self):
finding = validator_finding(
"reviewer.test",
"block",
"Validation",
"missing proof",
"repair validation proof",
)
self.assertEqual(finding["rule_id"], "reviewer.test")
self.assertEqual(finding["severity"], "block")
self.assertEqual(finding["field"], "Validation")
class TestReviewPrRules(unittest.TestCase):
def test_clean_reviewer_report_passes(self):
result = assess_final_report_validator(
_review_handoff(),
"review_pr",
linked_issue_lock={"issue_number": 182, "pr_number": 203},
)
self.assertEqual(result["grade"], "A")
self.assertFalse(result["blocked"])
self.assertEqual(result["findings"], [])
def test_legacy_workspace_mutations_blocks(self):
report = _review_handoff() + "\n- Workspace mutations: none"
result = assess_final_report_validator(
report,
"review_pr",
mutations_observed=True,
)
self.assertEqual(result["grade"], "blocked")
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.legacy_workspace_mutations", rule_ids)
def test_vague_mutations_none_blocks(self):
report = _review_handoff().replace(
"- Mutations: review only",
"- Mutations: none",
)
result = assess_final_report_validator(
report,
"review_pr",
mutations_observed=True,
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.vague_mutations_none" for f in result["findings"])
)
def test_bare_pytest_without_cwd_downgrades(self):
report = _review_handoff().replace(
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Validation: pytest passed",
)
result = assess_final_report_validator(report, "review_pr")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(f["rule_id"] == "reviewer.validation_command_proof" for f in result["findings"])
)
def test_linked_issue_mismatch_blocks(self):
report = _review_handoff().replace("- Linked issue: #182", "- Linked issue: #999")
result = assess_final_report_validator(
report,
"review_pr",
linked_issue_lock={"issue_number": 182, "pr_number": 203},
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.linked_issue_mismatch" for f in result["findings"])
)
def test_approval_after_full_suite_failure_blocks_without_baseline(self):
report = (
_review_handoff()
.replace("- Review decision: request_changes", "- Review decision: approve")
+ "\nSubmitted 'approve' after full suite failed with 3 failed tests."
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.baseline_on_full_suite_failure"
for f in result["findings"]
)
)
def test_same_as_master_without_baseline_blocks(self):
report = _review_handoff() + "\nFailures are same as master baseline."
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.main_checkout_baseline" for f in result["findings"])
)
def test_already_landed_eligible_wording_blocks(self):
report = (
_review_handoff()
+ "\nPR is already landed and eligible for review next."
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.already_landed_eligible_wording"
for f in result["findings"]
)
)
def test_git_fetch_must_not_be_readonly_only(self):
report = (
_review_handoff()
.replace("- Git ref mutations: git fetch prgs master", "- Git ref mutations: none")
+ "\n- Read-only diagnostics: git fetch prgs master"
)
result = assess_final_report_validator(
report,
"review_pr",
action_log=[{"command": "git fetch prgs master", "performed": True}],
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.git_fetch_readonly" for f in result["findings"])
)
class TestReconciliationRules(unittest.TestCase):
def test_clean_reconcile_report_passes(self):
result = assess_final_report_validator(
_reconcile_handoff(),
"reconcile_already_landed",
)
self.assertEqual(result["grade"], "A")
def test_stale_author_field_blocks(self):
report = _reconcile_handoff() + "\n- PR number opened: #12"
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
for f in result["findings"]
)
)
def test_eligible_wording_for_landed_pr_blocks(self):
report = _reconcile_handoff() + "\nPR already landed and eligible for merge."
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
def test_linked_issue_status_without_live_proof_downgrades(self):
report = _reconcile_handoff().replace(
"- Linked issue live status: not verified in this session",
"- Linked issue live status: closed",
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(f["rule_id"] == "reconcile.linked_issue_live_status" for f in result["findings"])
)
def test_inventory_complete_requires_pagination_proof(self):
report = _reconcile_handoff() + "\nInventory complete for open PRs."
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(f["rule_id"] == "reconcile.inventory_pagination_proof" for f in result["findings"])
)
class TestEntryPoint(unittest.TestCase):
def test_unknown_task_kind_blocks(self):
result = assess_final_report_validator("report", "unknown_mode")
self.assertTrue(result["blocked"])
self.assertEqual(result["findings"][0]["rule_id"], "shared.unknown_task_kind")
def test_review_proofs_reexport(self):
from review_proofs import assess_final_report_validator as exported
self.assertTrue(callable(exported))
result = exported(_review_handoff(), "review_pr")
self.assertIn("grade", result)
def test_supported_task_kinds_include_review_and_reconcile(self):
self.assertIn("review_pr", FINAL_REPORT_TASK_KINDS)
self.assertIn("reconcile_already_landed", FINAL_REPORT_TASK_KINDS)
if __name__ == "__main__":
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
"""Git ref mutations must be reported, never hidden as diagnostics (#297).
``git fetch`` updates remote-tracking refs even though it edits no files.
Reports that ran fetch (or any ref-updating command) must carry a
``Git ref mutations`` entry and may not claim ``Git/worktree mutations:
None`` or classify the fetch as read-only diagnostics.
"""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import review_proofs
def _assess(report, commands):
return review_proofs.assess_git_ref_mutation_report(
report, command_log=commands)
class TestGitRefMutationDetection(unittest.TestCase):
def test_fetch_only_review_with_proper_ledger_passes(self):
report = "\n".join([
"Git ref mutations: fetched prgs/master",
"Review decision: APPROVE",
])
res = _assess(report, ["git fetch prgs master"])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(res["ref_mutating_commands"], ["git fetch prgs master"])
def test_fetch_without_ref_mutation_line_is_blocked(self):
report = "Review decision: APPROVE\nMutations: None\n"
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("git ref mutations" in r.lower() for r in res["reasons"]))
def test_fetch_with_remote_branch_requires_fetched_target_in_report(self):
report = "Git ref mutations: some refs updated\n"
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("prgs/master" in r for r in res["reasons"]))
def test_git_worktree_mutations_none_rejected_when_fetch_occurred(self):
report = "\n".join([
"Git ref mutations: fetched prgs/master",
"Git/worktree mutations: None",
])
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("git/worktree mutations" in r.lower() for r in res["reasons"]))
def test_fetch_classified_as_read_only_diagnostics_rejected(self):
report = "\n".join([
"Git ref mutations: fetched prgs/master",
"Read-only diagnostics: git fetch prgs master, git status",
])
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("read-only" in r.lower() for r in res["reasons"]))
def test_worktree_creation_is_not_a_ref_mutation(self):
report = "Worktree mutations: created branches/review-pr9\n"
res = _assess(report, ["git worktree add branches/review-pr9 prgs/master"])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(res["ref_mutating_commands"], [])
def test_merge_command_counts_as_ref_mutation(self):
report = "Review decision: APPROVE\n"
res = _assess(report, ["git merge --no-ff feat/x"])
self.assertFalse(res["proven"])
self.assertIn("git merge --no-ff feat/x", res["ref_mutating_commands"])
def test_cleanup_branch_delete_counts_as_ref_mutation(self):
report = "Cleanup mutations: deleted branch feat/x\n"
res = _assess(report, ["git branch -d feat/x"])
self.assertFalse(res["proven"])
self.assertIn("git branch -d feat/x", res["ref_mutating_commands"])
def test_no_mutation_blocked_handoff_passes(self):
report = "\n".join([
"Git/worktree mutations: None",
"Current status: blocked handoff, no mutations performed",
])
res = _assess(report, ["git status --porcelain", "git log --oneline -1"])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(res["ref_mutating_commands"], [])
def test_command_log_dict_entries_supported(self):
report = "Git ref mutations: fetched prgs/master\n"
res = _assess(report, [{"command": "git fetch prgs master"}])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(
res["ref_mutating_commands"], ["git fetch prgs master"])
if __name__ == "__main__":
unittest.main()
+194 -17
View File
@@ -1,11 +1,66 @@
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import capability_stop_terminal
import role_session_router
from role_session_router import python_bytes_have_conflict_markers
from mcp_server import gitea_route_task_session, gitea_resolve_task_capability
_HEALTH_SUBPROCESS_TIMEOUT_SEC = 30
def _health_test_python():
"""Portable interpreter for subprocess health checks (#245).
Prefer the active pytest interpreter, then ``GITEA_TOOLS_TEST_PYTHON``,
else skip never hard-code ``<repo>/venv/bin/python``.
"""
if (
sys.executable
and os.path.isfile(sys.executable)
and os.access(sys.executable, os.X_OK)
):
return sys.executable
override = (os.environ.get("GITEA_TOOLS_TEST_PYTHON") or "").strip()
if override and os.path.isfile(override) and os.access(override, os.X_OK):
return override
raise unittest.SkipTest(
"repo venv not available; run under a python interpreter or set "
"GITEA_TOOLS_TEST_PYTHON"
)
def _run_python_script(cwd, script_path, *, timeout=_HEALTH_SUBPROCESS_TIMEOUT_SEC):
"""Run a script in a child process with timeout and guaranteed teardown."""
python = _health_test_python()
proc = subprocess.Popen(
[python, script_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cwd,
)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
raise
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
return proc.returncode, stdout, stderr, proc
class TestMCPHealth(unittest.TestCase):
def setUp(self):
@@ -18,6 +73,31 @@ class TestMCPHealth(unittest.TestCase):
if os.path.exists(self.temp_file):
os.remove(self.temp_file)
def test_health_test_python_prefers_sys_executable(self):
resolved = _health_test_python()
self.assertEqual(resolved, sys.executable)
def test_health_test_python_skips_when_no_interpreter(self):
with patch.object(sys, "executable", ""), patch.dict(
os.environ, {}, clear=True
):
with self.assertRaises(unittest.SkipTest):
_health_test_python()
def test_conflict_marker_helper_ignores_decorative_equals_border(self):
sample = b'banner = """\n===========================================\n"""\n'
self.assertFalse(python_bytes_have_conflict_markers(sample))
def test_conflict_marker_helper_detects_real_markers(self):
sample = (
b"<" * 7 + b" HEAD\n"
b"print('hello')\n"
b"=" * 7 + b"\n"
b"print('world')\n"
b">" * 7 + b" main\n"
)
self.assertTrue(python_bytes_have_conflict_markers(sample))
def test_startup_conflict_detection(self):
# Create a Python file with conflict markers constructed dynamically
with open(self.temp_file, "w") as f:
@@ -27,32 +107,129 @@ class TestMCPHealth(unittest.TestCase):
f.write("print('world')\n")
f.write(">" * 7 + " main\n")
# Run mcp_server.py
venv_python = os.path.join(self.project_root, "venv", "bin", "python")
script_path = os.path.join(self.project_root, "mcp_server.py")
res = subprocess.run(
[venv_python, script_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=self.project_root
returncode, _stdout, stderr, _proc = _run_python_script(
self.project_root, script_path
)
self.assertEqual(res.returncode, 1)
stderr = res.stderr.decode()
self.assertIn("infra_stop", stderr)
self.assertIn("Unresolved merge conflict detected in test_temp_conflict.py", stderr)
self.assertEqual(returncode, 1)
stderr_text = stderr.decode()
self.assertIn("infra_stop", stderr_text)
self.assertIn(
"Unresolved merge conflict detected in test_temp_conflict.py",
stderr_text,
)
@patch("role_session_router.check_mid_merge", return_value=True)
@patch("mcp_server.get_profile", return_value={"profile_name": "prgs-reviewer", "allowed_operations": ["gitea.pr.review"]})
def test_route_task_session_blocks_during_merge(self, mock_profile, mock_check):
@patch("role_session_router.assess_infra_stop")
@patch(
"mcp_server.get_profile",
return_value={
"profile_name": "prgs-reviewer",
"allowed_operations": ["gitea.pr.review"],
},
)
def test_route_task_session_blocks_during_merge(self, mock_profile, mock_assess):
mock_assess.return_value = {
"infra_stop": True,
"project_root": "/repo",
"conflict_file": None,
"mid_merge": True,
"infra_stop_reasons": [
"mid-merge markers detected in /repo/.git/MERGE_HEAD (project_root=/repo)"
],
}
res = gitea_route_task_session("review_pr")
self.assertEqual(res["route_result"], "infra_stop")
self.assertIn("infra_stop", res["message"])
self.assertTrue(res.get("infra_stop_assessment", {}).get("infra_stop"))
mock_assess.assert_called()
@patch("role_session_router.check_mid_merge", return_value=True)
@patch("mcp_server.get_profile", return_value={"profile_name": "prgs-reviewer", "allowed_operations": ["gitea.pr.review"]})
def test_resolve_task_capability_blocks_during_merge(self, mock_profile, mock_check):
@patch("role_session_router.assess_infra_stop")
@patch(
"mcp_server.get_profile",
return_value={
"profile_name": "prgs-reviewer",
"allowed_operations": ["gitea.pr.review"],
},
)
def test_resolve_task_capability_blocks_during_merge(
self, mock_profile, mock_assess
):
mock_assess.return_value = {
"infra_stop": True,
"project_root": "/repo",
"conflict_file": "/repo/gitea_mcp_server.py",
"mid_merge": False,
"infra_stop_reasons": [
"conflict markers detected in /repo/gitea_mcp_server.py (project_root=/repo)"
],
}
res = gitea_resolve_task_capability("review_pr")
self.assertTrue(res["infra_stop"])
self.assertFalse(res["allowed_in_current_session"])
self.assertIn("infra_stop", res["exact_safe_next_action"])
self.assertEqual(
res["infra_stop_assessment"]["conflict_file"],
"/repo/gitea_mcp_server.py",
)
@patch("role_session_router.assess_infra_stop")
@patch("mcp_server._authenticated_username", return_value="reviewer-user")
@patch(
"mcp_server.get_profile",
return_value={
"profile_name": "prgs-reviewer",
"allowed_operations": ["gitea.pr.review", "gitea.pr.approve"],
},
)
def test_resolve_task_capability_clears_stale_terminal_when_infra_clean(
self, mock_profile, _username, mock_assess
):
mock_assess.return_value = {
"infra_stop": False,
"project_root": "/repo",
"conflict_file": None,
"mid_merge": False,
"infra_stop_reasons": [],
}
capability_stop_terminal.enter_from_capability_result({
"requested_task": "review_pr",
"required_role_kind": "reviewer",
"stop_required": True,
"active_profile": "prgs-reviewer",
"active_identity": "reviewer-user",
"exact_safe_next_action": "blocked",
})
self.assertTrue(capability_stop_terminal.is_active())
res = gitea_resolve_task_capability("review_pr", remote="prgs")
self.assertFalse(capability_stop_terminal.is_active())
self.assertTrue(res["allowed_in_current_session"])
class TestInfraStopLiveAssessment(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tempdir, ignore_errors=True)
def test_clean_project_root_is_not_infra_stop(self):
infra = role_session_router.assess_infra_stop(self.tempdir)
self.assertFalse(infra["infra_stop"])
self.assertIsNone(infra["conflict_file"])
def test_conflict_present_then_resolved_recomputes_allowed(self):
conflict_path = os.path.join(self.tempdir, "conflicted.py")
with open(conflict_path, "wb") as handle:
handle.write(b"<<<<<<< HEAD\n")
blocked = role_session_router.assess_infra_stop(self.tempdir)
self.assertTrue(blocked["infra_stop"])
self.assertEqual(
os.path.realpath(blocked["conflict_file"]),
os.path.realpath(conflict_path),
)
os.remove(conflict_path)
cleared = role_session_router.assess_infra_stop(self.tempdir)
self.assertFalse(cleared["infra_stop"])
self.assertIsNone(cleared["conflict_file"])
+102
View File
@@ -0,0 +1,102 @@
"""Identity-disclosure checks for workflow reports (#305).
Workflow reports sometimes included the authenticated user's personal
email even though username/profile identity is sufficient (observed:
``Identity: jcwalker3 / jcwalker3@yahoo.com`` in a reconciliation
handoff). These tests pin the no-email identity summary helper and the
final-report validator that flags unnecessary email disclosure.
"""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import review_proofs
class TestIdentitySummary(unittest.TestCase):
def test_author_summary_uses_username_and_profile_only(self):
summary = review_proofs.format_identity_summary(
"jcwalker3", "prgs-author")
self.assertEqual(summary, "jcwalker3 / prgs-author")
self.assertNotIn("@", summary)
def test_reviewer_summary_uses_username_and_profile_only(self):
summary = review_proofs.format_identity_summary(
"sysadmin", "prgs-reviewer")
self.assertEqual(summary, "sysadmin / prgs-reviewer")
def test_summary_appends_role_and_remote_without_email(self):
summary = review_proofs.format_identity_summary(
"jcwalker3", "prgs-author", role="author", remote="prgs")
self.assertIn("jcwalker3 / prgs-author", summary)
self.assertIn("author", summary)
self.assertIn("prgs", summary)
self.assertNotIn("@", summary)
def test_summary_never_leaks_email_passed_as_username(self):
"""Defense in depth: an email in the username slot is reduced."""
summary = review_proofs.format_identity_summary(
"[email protected]", "prgs-author")
self.assertNotIn("@", summary)
self.assertIn("jcwalker3", summary)
class TestEmailDisclosureAssessment(unittest.TestCase):
def test_report_without_email_passes(self):
report = "\n".join([
"Controller Handoff",
"Identity: jcwalker3 / prgs-author",
"Role: author",
])
res = review_proofs.assess_email_disclosure(report)
self.assertTrue(res["proven"])
self.assertFalse(res["flagged"])
self.assertEqual(res["emails"], [])
self.assertEqual(res["reasons"], [])
def test_unnecessary_email_is_flagged(self):
report = "\n".join([
"Controller Handoff",
"Identity: jcwalker3 / [email protected]",
])
res = review_proofs.assess_email_disclosure(report)
self.assertFalse(res["proven"])
self.assertTrue(res["flagged"])
self.assertIn("[email protected]", res["emails"])
self.assertTrue(res["reasons"])
self.assertFalse(res["justified"])
def test_multiple_emails_all_reported(self):
report = (
"Identity: [email protected] author\n"
"Reviewer: [email protected]\n"
)
res = review_proofs.assess_email_disclosure(report)
self.assertTrue(res["flagged"])
self.assertEqual(
sorted(res["emails"]),
["[email protected]", "[email protected]"],
)
def test_justified_email_with_explanation_is_not_flagged(self):
report = "\n".join([
"Identity: jcwalker3 / prgs-author",
"Contact email: [email protected]",
"Email required because two accounts share the username and the",
"address is necessary to disambiguate identity.",
])
res = review_proofs.assess_email_disclosure(report)
self.assertFalse(res["flagged"])
self.assertTrue(res["justified"])
self.assertIn("[email protected]", res["emails"])
def test_email_without_justification_language_is_flagged(self):
report = "Contact email: [email protected] just in case.\n"
res = review_proofs.assess_email_disclosure(report)
self.assertTrue(res["flagged"])
self.assertFalse(res["justified"])
if __name__ == "__main__":
unittest.main()
+124
View File
@@ -0,0 +1,124 @@
"""Tests for issue-lock worktree validation (#249)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import issue_lock_worktree # noqa: E402
class TestIssueLockWorktreeAssessment(unittest.TestCase):
def test_clean_base_branch_passes(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/scratch/wt",
current_branch="master",
porcelain_status="",
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_dirty_tracked_files_fail(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/scratch/wt",
current_branch="master",
porcelain_status=" M gitea_mcp_server.py\n",
)
self.assertFalse(result["proven"])
self.assertIn("tracked file edits exist before issue lock", result["reasons"][0])
def test_feature_branch_fails(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/scratch/wt",
current_branch="feat/issue-243-forbidden-git-gaps",
porcelain_status="",
)
self.assertFalse(result["proven"])
self.assertIn("base-equivalence could not be proven", result["reasons"][0])
def test_base_equivalent_feature_branch_passes(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/repo/branches/issue-275",
current_branch="feat/issue-275-claim-lock-branches-worktree",
porcelain_status="",
base_equivalent=True,
inspected_git_root="/repo/branches/issue-275",
base_branch="origin/master",
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
self.assertEqual(result["base_branch"], "origin/master")
def test_non_base_equivalent_branch_fails(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/repo/branches/issue-275",
current_branch="feat/issue-275-claim-lock-branches-worktree",
porcelain_status="",
base_equivalent=False,
inspected_git_root="/repo/branches/issue-275",
base_branch=None,
)
self.assertFalse(result["proven"])
self.assertIn("must be base-equivalent", result["reasons"][0])
def test_untracked_files_do_not_block(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/scratch/wt",
current_branch="main",
porcelain_status="?? notes.txt\n",
)
self.assertTrue(result["proven"])
class TestIssueLockWorktreeResolution(unittest.TestCase):
def test_explicit_path_wins(self):
resolved = issue_lock_worktree.resolve_author_worktree_path(
"/tmp/scratch/wt", "/shared/dev"
)
self.assertEqual(resolved, os.path.realpath("/tmp/scratch/wt"))
def test_env_var_when_explicit_missing(self):
with patch.dict(os.environ, {"GITEA_AUTHOR_WORKTREE": "/tmp/from-env"}):
resolved = issue_lock_worktree.resolve_author_worktree_path(
None, "/shared/dev"
)
self.assertEqual(resolved, os.path.realpath("/tmp/from-env"))
def test_project_root_fallback(self):
resolved = issue_lock_worktree.resolve_author_worktree_path(
None, "/shared/dev"
)
self.assertEqual(resolved, os.path.realpath("/shared/dev"))
class TestPrWorktreeMatch(unittest.TestCase):
def test_matching_paths_pass(self):
result = issue_lock_worktree.verify_pr_worktree_matches_lock(
"/tmp/scratch/wt",
"/tmp/scratch/wt",
"/shared/dev",
)
self.assertTrue(result["proven"])
def test_mismatch_fails(self):
result = issue_lock_worktree.verify_pr_worktree_matches_lock(
"/tmp/scratch/wt",
"/shared/dev",
"/shared/dev",
)
self.assertFalse(result["proven"])
self.assertIn("does not match locked worktree", result["reasons"][0])
def test_missing_locked_path_skips_check(self):
result = issue_lock_worktree.verify_pr_worktree_matches_lock(
None,
"/any/path",
"/shared/dev",
)
self.assertTrue(result["proven"])
if __name__ == "__main__":
unittest.main()
+118
View File
@@ -0,0 +1,118 @@
"""Tests for linked-issue consistency verifier (#314).
Reviewer reports must verify the linked issue live for the selected PR;
stale issue numbers from a previous PR must not leak into the final
report, and linked-issue status must never be claimed without live proof.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_linked_issue_consistency # noqa: E402
class TestCorrectLinkedIssue(unittest.TestCase):
def test_matching_linked_issue_passes(self):
report = (
"Selected PR: 280\n"
"Linked issue status: Issue #275 open, closes on merge\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertTrue(result["complete"])
self.assertFalse(result["downgraded"])
self.assertEqual(result["reasons"], [])
def test_multiple_linked_issues_all_reported_passes(self):
report = (
"Linked issue status: Issue #275 and Issue #276 both open\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275, 276]
)
self.assertTrue(result["complete"])
def test_multiple_linked_issues_one_missing_fails(self):
report = "Linked issue status: Issue #275 open\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275, 276]
)
self.assertFalse(result["complete"])
self.assertTrue(
any("276" in r for r in result["reasons"])
)
class TestStaleIssueLeak(unittest.TestCase):
def test_stale_issue_in_status_field_fails(self):
# #314 observed behavior: PR #280 links Issue #275 but the report
# carries Issue #260 from the previous PR.
report = "Linked issue status: Issue #260 open\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertFalse(result["complete"])
self.assertTrue(any("260" in r for r in result["reasons"]))
def test_stale_issue_in_diagnostics_fails(self):
report = (
"Linked issue status: Issue #275 open\n"
"Read-only diagnostics: viewed Issue #260 status\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertFalse(result["complete"])
self.assertTrue(
any("stale" in r.lower() and "260" in r for r in result["reasons"])
)
def test_selected_pr_number_mention_is_not_stale(self):
# "PR #280" mentions must not be confused with issue mentions.
report = (
"Selected PR: 280\n"
"Validation: ran tests at PR #280 head\n"
"Linked issue status: Issue #275 open\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertTrue(result["complete"])
class TestMissingProof(unittest.TestCase):
def test_status_claim_without_live_proof_fails(self):
report = "Linked issue status: Issue #275 open\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=None
)
self.assertFalse(result["complete"])
self.assertTrue(
any("live proof" in r.lower() for r in result["reasons"])
)
def test_unverified_wording_without_proof_passes(self):
report = (
"Linked issue status: not verified in this session\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=None
)
self.assertTrue(result["complete"])
def test_missing_field_fails(self):
report = "Selected PR: 280\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertFalse(result["complete"])
self.assertTrue(
any("linked issue status" in r.lower() for r in result["reasons"])
)
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
"""Tests for gitea_list_prs pagination metadata (#340)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_auth # noqa: E402
from mcp_server import gitea_list_prs # noqa: E402
from review_proofs import assess_list_prs_pagination_proof # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
URL = "https://gitea.example.com/api/v1/repos/o/r/pulls?state=open"
SAMPLE_PR = {
"number": 1,
"title": "PR 1",
"state": "open",
"head": {"ref": "branch1"},
"base": {"ref": "master"},
"mergeable": True,
"updated_at": "2026-07-05T12:00:00Z",
}
class TestApiFetchPage(unittest.TestCase):
@patch("gitea_auth.api_request")
def test_final_page_metadata(self, mock_req):
mock_req.return_value = [SAMPLE_PR]
items, meta = gitea_auth.api_fetch_page(URL, FAKE_AUTH, page=1, limit=50)
self.assertEqual(len(items), 1)
self.assertFalse(meta["has_more"])
self.assertTrue(meta["is_final_page"])
self.assertIsNone(meta["next_page"])
@patch("gitea_auth.api_request")
def test_full_page_has_more(self, mock_req):
mock_req.return_value = [{"id": i} for i in range(2)]
items, meta = gitea_auth.api_fetch_page(URL, FAKE_AUTH, page=1, limit=2)
self.assertEqual(len(items), 2)
self.assertTrue(meta["has_more"])
self.assertFalse(meta["is_final_page"])
self.assertEqual(meta["next_page"], 2)
class TestGiteaListPrsPagination(unittest.TestCase):
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_explicit_page_returns_has_more(self, _auth, mock_fetch):
mock_fetch.return_value = (
[SAMPLE_PR],
{
"page": 1,
"per_page": 50,
"returned_count": 1,
"has_more": False,
"next_page": None,
"is_final_page": True,
},
)
with patch.dict(os.environ, {}, clear=True):
result = gitea_list_prs(page=1, per_page=50)
self.assertEqual(len(result["prs"]), 1)
self.assertFalse(result["pagination"]["inventory_complete"])
self.assertTrue(result["pagination"]["is_final_page"])
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_multi_page_fetch_all_marks_inventory_complete(self, _auth, mock_fetch):
mock_fetch.side_effect = [
(
[dict(SAMPLE_PR, number=i) for i in (1, 2)],
{
"page": 1,
"per_page": 2,
"returned_count": 2,
"has_more": True,
"next_page": 2,
"is_final_page": False,
},
),
(
[dict(SAMPLE_PR, number=3)],
{
"page": 2,
"per_page": 2,
"returned_count": 1,
"has_more": False,
"next_page": None,
"is_final_page": True,
},
),
]
with patch.dict(os.environ, {}, clear=True):
result = gitea_list_prs(per_page=2)
self.assertEqual([p["number"] for p in result["prs"]], [1, 2, 3])
self.assertTrue(result["pagination"]["inventory_complete"])
self.assertEqual(result["pagination"]["pages_fetched"], 2)
self.assertEqual(result["pagination"]["total_count"], 3)
class TestListPrsPaginationProof(unittest.TestCase):
def test_wrapped_final_page_passes_completeness_claim(self):
response = {
"prs": [],
"pagination": {
"page": 1,
"per_page": 50,
"returned_count": 0,
"has_more": False,
"next_page": None,
"is_final_page": True,
"inventory_complete": True,
"pages_fetched": 1,
"total_count": 0,
},
}
result = assess_list_prs_pagination_proof(
response, inventory_complete_claimed=True
)
self.assertTrue(result["proven"])
def test_bare_list_blocks_completeness_claim(self):
result = assess_list_prs_pagination_proof(
[{"number": 1}], inventory_complete_claimed=True
)
self.assertFalse(result["proven"])
self.assertIn("pagination metadata", " ".join(result["reasons"]))
def test_single_page_without_complete_blocks_claim(self):
response = {
"prs": [{"number": 1}],
"pagination": {
"page": 1,
"per_page": 50,
"returned_count": 1,
"has_more": True,
"next_page": 2,
"is_final_page": False,
"inventory_complete": False,
"pages_fetched": 1,
"total_count": None,
},
}
result = assess_list_prs_pagination_proof(
response, inventory_complete_claimed=True
)
self.assertFalse(result["proven"])
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
"""Doc-contract checks for task-specific LLM workflow split (#333, #334)."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILL_DIR = REPO_ROOT / "skills" / "llm-project-workflow"
SKILL = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
def test_skill_md_exists():
assert SKILL_DIR.joinpath("SKILL.md").is_file()
def test_all_workflow_files_exist():
for name in (
"review-merge-pr.md",
"reconcile-landed-pr.md",
"create-issue.md",
"work-issue.md",
):
assert (SKILL_DIR / "workflows" / name).is_file(), name
def test_skill_references_all_workflow_files():
for name in (
"workflows/review-merge-pr.md",
"workflows/reconcile-landed-pr.md",
"workflows/create-issue.md",
"workflows/work-issue.md",
):
assert name in SKILL
def test_skill_contains_mode_isolation_language():
assert "## Mode isolation" in SKILL
assert "review-merge-pr" in SKILL
assert "reconcile-landed-pr" in SKILL
assert "create-issue" in SKILL
assert "work-issue" in SKILL
assert "Do not mix modes" in SKILL or "do not mix modes" in SKILL.lower()
def test_skill_is_router_not_monolithic_review_body():
assert "This skill is a **router**" in SKILL or "router" in SKILL.lower()
assert "## F. Review workflow" not in SKILL
assert "gitea_mark_final_review_decision" not in SKILL
assert "gitea_submit_pr_review" not in SKILL
def test_skill_still_declares_controller_handoff_contract():
assert "## Controller Handoff" in SKILL
assert "assess_controller_handoff" in SKILL
assert "## Global LLM Worktree Rule" in SKILL
def test_review_merge_workflow_contract():
text = (SKILL_DIR / "workflows" / "review-merge-pr.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "## 0. Load the canonical workflow first" in text
assert "## 26A. Terminal review mutation hard-stop" in text
assert "## 11A. Skipped PRs are read-only" in text
assert "## 35. Duplicate request-changes prevention" in text
assert "## 37. Controller handoff schema" in text
assert "INVENTORY_PAGINATION_UNPROVEN" in text
assert "ALREADY_LANDED_RECONCILE_REQUIRED" in text
def test_review_merge_final_report_schema_exists():
path = SKILL_DIR / "schemas" / "review-merge-final-report.md"
text = path.read_text(encoding="utf-8")
assert "Controller Handoff" in text
assert "Candidate head SHA:" in text
assert "Terminal review mutation:" in text
assert "Workspace mutations" in text
def test_reconcile_landed_workflow_contract():
text = (SKILL_DIR / "workflows" / "reconcile-landed-pr.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "Do not review or merge normal PRs" in text
assert "Already-landed proof" in text
def test_create_issue_workflow_contract():
text = (SKILL_DIR / "workflows" / "create-issue.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "## 9. Duplicate search before mutation" in text
def test_work_issue_workflow_contract():
text = (SKILL_DIR / "workflows" / "work-issue.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "Do not merge your own PR" in text
assert "## 21. PR creation rules" in text
def test_final_report_schemas_exist():
for name in (
"review-merge-final-report.md",
"reconcile-landed-final-report.md",
"create-issue-final-report.md",
"work-issue-final-report.md",
):
assert (SKILL_DIR / "schemas" / name).is_file(), name
def test_review_pr_template_references_workflow():
text = (SKILL_DIR / "templates" / "review-pr.md").read_text(encoding="utf-8")
assert "workflows/review-merge-pr.md" in text
def test_merge_pr_template_references_workflow():
text = (SKILL_DIR / "templates" / "merge-pr.md").read_text(encoding="utf-8")
assert "workflows/review-merge-pr.md" in text
def test_start_issue_template_references_work_issue_workflow():
text = (SKILL_DIR / "templates" / "start-issue.md").read_text(encoding="utf-8")
assert "workflows/work-issue.md" in text
def test_reviewer_fallback_verifier_exported():
from review_proofs import assess_reviewer_fallback_report
assert callable(assess_reviewer_fallback_report)
def test_final_report_validator_exported():
from review_proofs import assess_final_report_validator
assert callable(assess_final_report_validator)
def test_create_issue_final_report_verifier_exported():
from review_proofs import assess_create_issue_final_report
assert callable(assess_create_issue_final_report)
def test_prior_blocker_skip_verifier_exported():
from review_proofs import assess_prior_blocker_skip_proof
assert callable(assess_prior_blocker_skip_proof)
def test_non_mergeable_skip_verifier_exported():
from review_proofs import assess_non_mergeable_skip_proof
assert callable(assess_non_mergeable_skip_proof)
+260
View File
@@ -0,0 +1,260 @@
"""Tests for MCP discoverability validation (Issue #155)."""
import os
import sys
import json
import tempfile
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from mcp_discoverability import (
validate_mcp_client_config,
RELOAD_INSTRUCTIONS,
EXPECTED_JENKINS_TOOLS,
EXPECTED_GLITCHTIP_TOOLS,
)
class TestMcpDiscoverability(unittest.TestCase):
def setUp(self):
self.tmp_dir = tempfile.TemporaryDirectory()
self.gitea_config_path = os.path.join(self.tmp_dir.name, "gitea-mcp.json")
self.client_config_path = os.path.join(self.tmp_dir.name, "claude_desktop_config.json")
def tearDown(self):
self.tmp_dir.cleanup()
def _write_json(self, path, data):
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh)
def _v2_gitea_config(self, jenkins_enabled=True, glitchtip_enabled=False):
return {
"version": 2,
"contexts": {
"prod": {
"enabled": True,
"services": {
"jenkins": {
"enabled": jenkins_enabled,
"kind": "jenkins",
"capabilities": ["read"]
},
"glitchtip": {
"enabled": glitchtip_enabled,
"kind": "glitchtip",
"capabilities": ["read"]
}
}
}
}
}
def test_static_validation_success(self):
g_cfg = self._v2_gitea_config(jenkins_enabled=True, glitchtip_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
c_cfg = {
"mcpServers": {
"jenkins-mcp": {
"command": "python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_PROFILE": "jenkins-readonly",
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
},
"glitchtip-mcp": {
"command": "python3",
"args": ["-m", "glitchtip_mcp"],
"env": {
"GLITCHTIP_MCP_PROFILE": "glitchtip-readonly",
"GLITCHTIP_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=False
)
self.assertTrue(res)
def test_stale_server_name_rejected(self):
g_cfg = self._v2_gitea_config(jenkins_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
# Uses stale key in client config
c_cfg = {
"mcpServers": {
"jenkins-readonly": {
"command": "python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_PROFILE": "jenkins-readonly",
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=False
)
self.assertFalse(res)
def test_incorrect_arguments_rejected(self):
g_cfg = self._v2_gitea_config(jenkins_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
# Missing -m or wrong module
c_cfg = {
"mcpServers": {
"jenkins-mcp": {
"command": "python3",
"args": ["wrong_runner.py"],
"env": {
"JENKINS_MCP_PROFILE": "jenkins-readonly",
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=False
)
self.assertFalse(res)
def test_missing_required_env_rejected(self):
g_cfg = self._v2_gitea_config(jenkins_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
# Missing JENKINS_MCP_PROFILE env variable
c_cfg = {
"mcpServers": {
"jenkins-mcp": {
"command": "python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=False
)
self.assertFalse(res)
@patch("subprocess.Popen")
def test_live_check_verification_success(self, mock_popen):
g_cfg = self._v2_gitea_config(jenkins_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
c_cfg = {
"mcpServers": {
"jenkins-mcp": {
"command": "python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_PROFILE": "jenkins-readonly",
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
# Mock stdout lines for JSON-RPC
mock_proc = MagicMock()
mock_popen.return_value = mock_proc
init_resp = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2024-11-05"}})
tools_resp = json.dumps({
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [{"name": tool} for tool in EXPECTED_JENKINS_TOOLS]
}
})
mock_proc.stdout.readline.side_effect = [
init_resp + "\n",
tools_resp + "\n",
""
]
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=True
)
self.assertTrue(res)
@patch("subprocess.Popen")
def test_live_check_empty_toolset_rejected(self, mock_popen):
g_cfg = self._v2_gitea_config(jenkins_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
c_cfg = {
"mcpServers": {
"jenkins-mcp": {
"command": "python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_PROFILE": "jenkins-readonly",
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
mock_proc = MagicMock()
mock_popen.return_value = mock_proc
init_resp = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}})
tools_resp = json.dumps({
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [] # empty tool list
}
})
mock_proc.stdout.readline.side_effect = [
init_resp + "\n",
tools_resp + "\n",
""
]
# Should return False (coverage fails) when no tools are exposed
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=True
)
self.assertFalse(res)
def test_runbook_instructions_content(self):
self.assertIn("MCP CLIENT RELOAD/RECONNECT RUNBOOK", RELOAD_INSTRUCTIONS)
self.assertIn("Codex", RELOAD_INSTRUCTIONS)
self.assertIn("Gemini", RELOAD_INSTRUCTIONS)
self.assertIn("Claude Desktop", RELOAD_INSTRUCTIONS)
if __name__ == "__main__":
unittest.main()
+551 -73
View File
@@ -24,6 +24,7 @@ from mcp_server import ( # noqa: E402
gitea_merge_pr,
gitea_review_pr,
gitea_delete_branch,
gitea_reconcile_merged_cleanups,
gitea_edit_pr,
gitea_get_file,
gitea_commit_files,
@@ -47,6 +48,37 @@ import mcp_server
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
_NO_BLOCKER_FEEDBACK = {
"success": True,
"has_blocking_change_requests": False,
"review_feedback_stale": False,
}
def _mark_request_changes_ready(pr_number=8, **kwargs):
"""Mark a request_changes decision ready with the #332 duplicate-
suppression feedback fetch stubbed to 'no existing blocker'."""
with patch("mcp_server.gitea_get_pr_review_feedback",
return_value=dict(_NO_BLOCKER_FEEDBACK)):
return gitea_mark_final_review_decision(
pr_number, "request_changes", **kwargs)
def _formal_review(reviewer, verdict, sha="abc123", review_id=1):
return {
"id": review_id,
"user": {"login": reviewer},
"state": verdict,
"commit_id": sha,
"submitted_at": "2026-07-06T10:00:00Z",
"dismissed": False,
}
def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"):
return [_formal_review(reviewer, "APPROVED", sha=sha)]
# Issue-write tools are profile-gated (#69).
ISSUE_WRITE_ENV = {
"GITEA_ALLOWED_OPERATIONS": (
@@ -65,6 +97,20 @@ CREATE_PR_ENV = {
),
}
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
def _sample_issue_lock(issue_number=123, branch_name="feat/x", **overrides):
record = {
"issue_number": issue_number,
"branch_name": branch_name,
"remote": "dadeschools",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
}
record.update(overrides)
return record
# ---------------------------------------------------------------------------
# Create Issue
@@ -127,15 +173,19 @@ class TestCreatePR(unittest.TestCase):
@patch("os.path.exists", return_value=True)
@patch("builtins.open")
def test_creates_pr(self, mock_open, mock_exists, _auth, mock_api, _role):
mock_open.return_value.__enter__.return_value.read.return_value = '{"issue_number": 123, "branch_name": "feat/x"}'
lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
mock_open.return_value.__enter__.return_value.read.return_value = lock_json
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
self.assertEqual(result["number"], 3)
self.assertNotIn("url", result)
mock_exists.assert_called_with(ISSUE_LOCK_FILE)
mock_open.assert_called_with(ISSUE_LOCK_FILE, "r", encoding="utf-8")
payload = mock_api.call_args[0][3]
self.assertEqual(payload["head"], "feat/x")
self.assertEqual(payload["base"], "main")
self.assertIn("Closes #123", payload["title"])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@@ -144,13 +194,28 @@ class TestCreatePR(unittest.TestCase):
@patch("os.path.exists", return_value=True)
@patch("builtins.open")
def test_create_pr_reveal_opt_in_includes_url(self, mock_open, mock_exists, _auth, mock_api, _role):
mock_open.return_value.__enter__.return_value.read.return_value = '{"issue_number": 123, "branch_name": "feat/x"}'
lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
mock_open.return_value.__enter__.return_value.read.return_value = lock_json
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
env = {**CREATE_PR_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
with patch.dict(os.environ, env, clear=True):
result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
self.assertIn("pulls/3", result["url"])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("os.path.exists", return_value=True)
@patch("builtins.open")
def test_create_pr_locked_issue_mismatch_fails(self, mock_open, mock_exists, _auth, _role):
lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
mock_open.return_value.__enter__.return_value.read.return_value = lock_json
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
with self.assertRaises(ValueError) as ctx:
gitea_create_pr(title="feat: X Closes #999", head="feat/x", base="main")
self.assertIn("Closes #123", str(ctx.exception))
mock_open.assert_called_with(ISSUE_LOCK_FILE, "r", encoding="utf-8")
# ---------------------------------------------------------------------------
# Close Issue
@@ -350,40 +415,49 @@ class TestMirrorRefs(unittest.TestCase):
# ---------------------------------------------------------------------------
class TestListPRs(unittest.TestCase):
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_prs(self, _auth, mock_api):
mock_api.return_value = [
{
def test_list_prs(self, _auth, mock_fetch):
mock_fetch.return_value = (
[{
"number": 1, "title": "PR 1", "state": "open",
"head": {"ref": "branch1"}, "base": {"ref": "main"},
"html_url": "http://url1", "mergeable": True,
"updated_at": "2026-07-05T12:00:00Z"
}
]
}],
{
"page": 1, "per_page": 50, "returned_count": 1,
"has_more": False, "next_page": None, "is_final_page": True,
},
)
with patch.dict(os.environ, {}, clear=True):
result = gitea_list_prs()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["number"], 1)
self.assertEqual(result[0]["head"], "branch1")
self.assertNotIn("url", result[0])
self.assertEqual(len(result["prs"]), 1)
self.assertEqual(result["prs"][0]["number"], 1)
self.assertEqual(result["prs"][0]["head"], "branch1")
self.assertNotIn("url", result["prs"][0])
self.assertTrue(result["pagination"]["inventory_complete"])
# Staleness fields for queue reconciliation (#166)
self.assertEqual(result[0]["updated_at"], "2026-07-05T12:00:00Z")
self.assertEqual(result["prs"][0]["updated_at"], "2026-07-05T12:00:00Z")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_prs_reveal_opt_in_includes_url(self, _auth, mock_api):
mock_api.return_value = [
{
def test_list_prs_reveal_opt_in_includes_url(self, _auth, mock_fetch):
mock_fetch.return_value = (
[{
"number": 1, "title": "PR 1", "state": "open",
"head": {"ref": "branch1"}, "base": {"ref": "main"},
"html_url": "http://url1", "mergeable": True,
"updated_at": "2026-07-05T12:00:00Z"
}
]
}],
{
"page": 1, "per_page": 50, "returned_count": 1,
"has_more": False, "next_page": None, "is_final_page": True,
},
)
with patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": "1"}, clear=True):
result = gitea_list_prs()
self.assertEqual(result[0]["url"], "http://url1")
self.assertEqual(result["prs"][0]["url"], "http://url1")
# ---------------------------------------------------------------------------
@@ -455,6 +529,10 @@ class TestMergePR(unittest.TestCase):
f"unexpected merge mutation: {method} {url}",
)
def _feedback_reads(self, author="author-bot", sha="abc123"):
"""PR + reviews GETs for gitea_get_pr_review_feedback during merge."""
return [self._pr(author, sha=sha), _visible_approval_reviews(sha=sha)]
# -- success --------------------------------------------------------------
@patch("mcp_server.api_request")
@@ -462,6 +540,7 @@ class TestMergePR(unittest.TestCase):
def test_merge_succeeds_when_all_gates_pass(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "merger-bot"}, self._pr("author-bot"),
*self._feedback_reads(),
{}, # merge POST
{"merged_commit_sha": "mergecommit99"}, # read-back
]
@@ -478,8 +557,8 @@ class TestMergePR(unittest.TestCase):
self.assertEqual(r["head_sha"], "abc123")
self.assertEqual(r["merge_method"], "squash")
self.assertEqual(r["merge_commit"], "mergecommit99")
# 3rd call is the merge POST with the requested method/title/message.
merge_call = mock_api.call_args_list[2]
# 5th call is the merge POST with the requested method/title/message.
merge_call = mock_api.call_args_list[4]
self.assertEqual(merge_call.args[0], "POST")
self.assertTrue(merge_call.args[1].endswith("/pulls/8/merge"))
payload = merge_call.args[3]
@@ -494,6 +573,7 @@ class TestMergePR(unittest.TestCase):
mock_api.side_effect = [
{"login": "merger-bot"}, self._pr("author-bot"),
[{"filename": "a.py"}, {"filename": "b.py"}], # files
*self._feedback_reads(),
{}, # merge POST
{"merged_commit_sha": "c1"}, # read-back
]
@@ -513,6 +593,7 @@ class TestMergePR(unittest.TestCase):
"""Merge OK + read-back GET failure => explicit cleanup skip, not silence."""
mock_api.side_effect = [
{"login": "merger-bot"}, self._pr("author-bot"),
*self._feedback_reads(),
{}, # merge POST
RuntimeError("HTTP 502: Gitea upstream unavailable"), # read-back fails
]
@@ -528,8 +609,8 @@ class TestMergePR(unittest.TestCase):
# The skip is explicit, not silent.
self.assertEqual(r["cleanup_status"], "skipped (merge read-back failed)")
# No tracker-cleanup API traffic after the failed read-back:
# user, PR (eligibility), merge POST, read-back — and nothing more.
self.assertEqual(mock_api.call_count, 4)
# user, PR (eligibility), feedback PR+reviews, merge POST, read-back.
self.assertEqual(mock_api.call_count, 6)
for c in mock_api.call_args_list:
self.assertNotEqual(c.args[0], "DELETE")
@@ -541,6 +622,7 @@ class TestMergePR(unittest.TestCase):
"""Unexpected cleanup exception => merge still succeeds; error surfaced redacted."""
mock_api.side_effect = [
{"login": "merger-bot"}, self._pr("author-bot"),
*self._feedback_reads(),
{}, # merge POST
{"merged_commit_sha": "c9"}, # read-back OK
]
@@ -728,6 +810,7 @@ class TestMergePR(unittest.TestCase):
def test_output_redacts_secrets(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "merger-bot"}, self._pr("author-bot"),
*self._feedback_reads(),
{}, {"merged_commit_sha": "c1"},
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
@@ -745,6 +828,7 @@ class TestMergePR(unittest.TestCase):
def test_merge_error_message_redacts_credential(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "merger-bot"}, self._pr("author-bot"),
*self._feedback_reads(),
RuntimeError("HTTP 500: token abc-secret-xyz rejected"),
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
@@ -757,6 +841,45 @@ class TestMergePR(unittest.TestCase):
self.assertIn("[REDACTED]", blob)
self.assertNotIn("abc-secret-xyz", blob)
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_merge_blocked_without_visible_approval(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "merger-bot"}, self._pr("author-bot"),
self._pr("author-bot"),
[_formal_review("sysadmin", "PENDING")],
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"])
self.assertFalse(r.get("approval_visible"))
self.assertTrue(any("no visible APPROVED review" in x for x in r["reasons"]))
self._assert_no_merge_call(mock_api)
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_merge_blocked_on_request_changes(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "merger-bot"}, self._pr("author-bot"),
self._pr("author-bot"),
[
_formal_review("reviewer-bot", "APPROVED"),
_formal_review("reviewer-bot", "REQUEST_CHANGES", review_id=2),
],
]
env = {"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"])
self.assertTrue(r.get("has_blocking_change_requests"))
self.assertTrue(any("REQUEST_CHANGES" in x for x in r["reasons"]))
self._assert_no_merge_call(mock_api)
class TestNoUngatedMergePath(unittest.TestCase):
"""Prove no other exposed tool can merge (#16 surface audit)."""
@@ -892,6 +1015,60 @@ class TestDeleteBranch(unittest.TestCase):
self.assertIn("feat%2Fbranch", url)
# ---------------------------------------------------------------------------
# Reconcile merged cleanups (#269)
# ---------------------------------------------------------------------------
READ_ENV = {
"GITEA_ALLOWED_OPERATIONS": "gitea.read",
}
class TestReconcileMergedCleanups(unittest.TestCase):
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_dry_run_builds_report_without_mutations(self, _auth, mock_api_get_all):
mock_api_get_all.side_effect = [
[
{
"number": 50,
"title": "merged feature",
"body": "Closes #50",
"merged": True,
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "deadbeef",
"head": {"ref": "feat/issue-50-example", "sha": "cafebabe"},
}
],
[],
]
with patch.dict(os.environ, READ_ENV, clear=True):
with patch(
"mcp_server._remote_branch_exists",
return_value=True,
):
with patch(
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
return_value=True,
):
result = gitea_reconcile_merged_cleanups(
dry_run=True,
remote="prgs",
limit=10,
)
self.assertTrue(result["success"])
self.assertTrue(result["dry_run"])
self.assertFalse(result["executed"])
self.assertEqual(result["merged_pr_count"], 1)
self.assertEqual(result["entries"][0]["issue_number"], 50)
def test_execute_requires_confirmation(self):
with patch.dict(os.environ, READ_ENV, clear=True):
with self.assertRaises(ValueError) as ctx:
gitea_reconcile_merged_cleanups(dry_run=False, execute_confirmed=False)
self.assertIn("execute_confirmed", str(ctx.exception))
# ---------------------------------------------------------------------------
# Edit PR
# ---------------------------------------------------------------------------
@@ -973,9 +1150,11 @@ class TestGetFile(unittest.TestCase):
# ---------------------------------------------------------------------------
class TestCommitFiles(unittest.TestCase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_commit_files_success(self, _auth, mock_api):
def test_commit_files_success(self, _auth, mock_api, _role):
mock_api.return_value = {
"commit": {"sha": "commit-sha-123"},
"branch": {"name": "test-branch"}
@@ -983,11 +1162,15 @@ class TestCommitFiles(unittest.TestCase):
files = [
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
]
result = gitea_commit_files(
files=files,
message="Initial commit",
new_branch="test-branch"
)
env = {
"GITEA_ALLOWED_OPERATIONS": "gitea.repo.commit",
}
with patch.dict(os.environ, env, clear=True):
result = gitea_commit_files(
files=files,
message="Initial commit",
new_branch="test-branch"
)
self.assertTrue(result["success"])
self.assertEqual(result["commit"], "commit-sha-123")
self.assertEqual(result["branch"], "test-branch")
@@ -1512,7 +1695,9 @@ class TestReviewDecisionValidationGate(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_duplicate_terminal_decision_blocked(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 1},
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 1, "state": "APPROVED"},
[_formal_review("reviewer-bot", "APPROVED", review_id=1)],
{"login": "reviewer-bot"}, self._pr("author-bot"),
]
gitea_mark_final_review_decision(
@@ -1595,7 +1780,9 @@ class TestSubmitPrReview(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_approve_succeeds_when_eligible(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 7},
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 7, "state": "APPROVED"},
[_formal_review("reviewer-bot", "APPROVED", review_id=7)],
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
@@ -1605,24 +1792,73 @@ class TestSubmitPrReview(unittest.TestCase):
final_review_decision_ready=True,
)
self.assertTrue(r["performed"])
self.assertTrue(r.get("review_verdict_visible"))
self.assertEqual(r["authenticated_user"], "reviewer-bot")
self.assertEqual(r["pr_author"], "author-bot")
self.assertEqual(r["head_sha"], "abc123")
method, url = mock_api.call_args.args[0], mock_api.call_args.args[1]
self.assertEqual(method, "POST")
self.assertTrue(url.endswith("/pulls/8/reviews"))
payload = mock_api.call_args.args[3]
self.assertEqual(payload["event"], "APPROVE")
post_calls = [
c for c in mock_api.call_args_list
if c.args[0] == "POST" and c.args[1].endswith("/pulls/8/reviews")
]
self.assertEqual(len(post_calls), 1)
payload = post_calls[0].args[3]
self.assertEqual(payload["event"], "APPROVED")
self.assertEqual(payload["commit_id"], "abc123")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_approve_fails_when_verdict_stays_pending(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 7, "state": "PENDING"},
{"id": 7, "state": "PENDING"},
[_formal_review("reviewer-bot", "PENDING", review_id=7)],
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
r = gitea_submit_pr_review(
pr_number=8, action="approve", body="LGTM", remote="prgs",
final_review_decision_ready=True,
)
self.assertFalse(r["performed"])
self.assertFalse(r.get("review_verdict_visible"))
self.assertTrue(any("expected visible 'APPROVED'" in x for x in r["reasons"]))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_approve_submits_pending_draft_when_api_returns_pending(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 7, "state": "PENDING"},
{"id": 7, "state": "APPROVED"},
[_formal_review("reviewer-bot", "APPROVED", review_id=7)],
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
r = gitea_submit_pr_review(
pr_number=8, action="approve", body="LGTM", remote="prgs",
final_review_decision_ready=True,
)
self.assertTrue(r["performed"])
submit_calls = [
c for c in mock_api.call_args_list
if c.args[0] == "POST" and "/reviews/7" in c.args[1]
]
self.assertEqual(len(submit_calls), 1)
self.assertEqual(submit_calls[0].args[3]["event"], "APPROVED")
# -- request_changes ------------------------------------------------------
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_request_changes_succeeds_when_eligible(self, _auth, mock_api):
gitea_mark_final_review_decision(8, "request_changes", remote="prgs")
_mark_request_changes_ready(remote="prgs")
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 9},
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 9, "state": "REQUEST_CHANGES"},
[_formal_review("reviewer-bot", "REQUEST_CHANGES", review_id=9)],
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,request_changes"}
@@ -1633,10 +1869,15 @@ class TestSubmitPrReview(unittest.TestCase):
final_review_decision_ready=True,
)
self.assertTrue(r["performed"])
self.assertEqual(mock_api.call_args.args[3]["event"], "REQUEST_CHANGES")
post_calls = [
c for c in mock_api.call_args_list
if c.args[0] == "POST" and c.args[1].endswith("/pulls/8/reviews")
]
self.assertEqual(len(post_calls), 1)
self.assertEqual(post_calls[0].args[3]["event"], "REQUEST_CHANGES")
def test_request_changes_blocked_without_eligibility(self):
gitea_mark_final_review_decision(8, "request_changes", remote="prgs")
_mark_request_changes_ready(remote="prgs")
with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) as _a, \
patch("mcp_server.api_request") as mock_api:
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
@@ -1744,7 +1985,8 @@ class TestSubmitPrReview(unittest.TestCase):
patch("mcp_server.api_request") as mock_api:
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot", sha="abc123"),
{"id": 5},
{"id": 5, "state": "APPROVED"},
[_formal_review("reviewer-bot", "APPROVED", review_id=5)],
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
@@ -1896,8 +2138,12 @@ class TestSubmitPrReview(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_correction_flow_allows_second_terminal_review(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 42},
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 43},
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 42, "state": "APPROVED"},
[_formal_review("reviewer-bot", "APPROVED", review_id=42)],
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 43, "state": "REQUEST_CHANGES"},
[_formal_review("reviewer-bot", "REQUEST_CHANGES", review_id=43)],
]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
@@ -1913,7 +2159,7 @@ class TestSubmitPrReview(unittest.TestCase):
reason="operator approved correcting mistaken approve",
operator_authorized=True,
)
gitea_mark_final_review_decision(8, "request_changes", remote="prgs")
_mark_request_changes_ready(remote="prgs")
second = gitea_submit_pr_review(
pr_number=8, action="request_changes", remote="prgs",
final_review_decision_ready=True,
@@ -1970,7 +2216,7 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
patch("gitea_audit.audit_enabled", return_value=True).start()
self.mock_audit = patch("gitea_audit.write_event").start()
# gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216).
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["merge", "edit", "close", "gitea.pr.close", "gitea.issue.close"], "audit_label": "test", "forbidden_operations": []}).start()
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["read", "merge", "edit", "close", "gitea.pr.close", "gitea.issue.close"], "audit_label": "test", "forbidden_operations": []}).start()
def tearDown(self):
patch.stopall()
@@ -2018,6 +2264,8 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/user" in url:
return {"login": "merger"}
if method == "GET" and url.endswith("/reviews"):
return [_formal_review("reviewer", "APPROVED", sha="sha123")]
if method == "GET" and "pulls/1" in url and "/files" not in url:
return {
"user": {"login": "author"},
@@ -2050,6 +2298,8 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/user" in url:
return {"login": "merger"}
if method == "GET" and url.endswith("/reviews"):
return [_formal_review("reviewer", "APPROVED", sha="sha123")]
if method == "GET" and "pulls/1" in url and "/files" not in url:
return {
"user": {"login": "author"},
@@ -2766,29 +3016,55 @@ class TestVerifyMutationAuthority(unittest.TestCase):
mcp_server.verify_mutation_authority("prgs")
def _clean_master_git_state_for_lock():
return {
"current_branch": "master",
"porcelain_status": "",
"base_equivalent": True,
"inspected_git_root": "/scratch/wt",
"base_branch": "origin/master",
}
class TestIssueLocking(unittest.TestCase):
"""Test issue locking and PR gating constraints."""
def tearDown(self):
if os.path.exists("/tmp/gitea_issue_lock.json"):
os.remove("/tmp/gitea_issue_lock.json")
def setUp(self):
self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
self._env_patcher.start()
def tearDown(self):
self._env_patcher.stop()
if os.path.exists(ISSUE_LOCK_FILE):
os.remove(ISSUE_LOCK_FILE)
@patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(),
)
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_success(self, _auth, mock_api):
def test_lock_issue_success(self, _auth, mock_api, _git_state):
mock_api.return_value = [] # no open PRs
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertTrue(res["success"])
self.assertTrue(os.path.exists("/tmp/gitea_issue_lock.json"))
self.assertTrue(os.path.exists(ISSUE_LOCK_FILE))
with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
lock = json.load(f)
self.assertIn("worktree_path", lock)
def test_lock_issue_mismatch_branch_fails(self):
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-195-mutations", remote="prgs")
self.assertIn("must contain locked issue pattern", str(ctx.exception))
@patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(),
)
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_reused_by_open_pr_branch(self, _auth, mock_api):
def test_lock_issue_reused_by_open_pr_branch(self, _auth, mock_api, _git_state):
mock_api.return_value = [{
"number": 200,
"head": {"ref": "feat/issue-196-boundary"},
@@ -2799,9 +3075,13 @@ class TestIssueLocking(unittest.TestCase):
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("already tied to an open PR", str(ctx.exception))
@patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(),
)
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_reused_by_open_pr_closes_ref(self, _auth, mock_api):
def test_lock_issue_reused_by_open_pr_closes_ref(self, _auth, mock_api, _git_state):
mock_api.return_value = [{
"number": 200,
"head": {"ref": "feat/other-branch"},
@@ -2812,12 +3092,70 @@ class TestIssueLocking(unittest.TestCase):
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("already tied to an open PR", str(ctx.exception))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_from_clean_scratch_worktree(self, _auth, _api):
scratch = "/tmp/gitea-tools-author-scratch/issue-249-clean"
with patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(),
) as mock_git:
res = gitea_lock_issue(
issue_number=249,
branch_name="feat/issue-249-issue-lock-scratch-worktree",
remote="prgs",
worktree_path=scratch,
)
self.assertTrue(res["success"])
self.assertEqual(res["worktree_path"], os.path.realpath(scratch))
mock_git.assert_called_once_with(os.path.realpath(scratch))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_fails_when_declared_worktree_dirty(self, _auth, _api):
with patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"porcelain_status": " M gitea_mcp_server.py\n",
"base_equivalent": True,
},
):
with self.assertRaises(RuntimeError) as ctx:
gitea_lock_issue(
issue_number=249,
branch_name="feat/issue-249-issue-lock-scratch-worktree",
remote="prgs",
worktree_path="/tmp/scratch/wt",
)
self.assertIn("tracked file edits exist before issue lock", str(ctx.exception))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_fails_when_declared_worktree_not_on_base(self, _auth, _api):
with patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "feat/issue-243-forbidden-git-gaps",
"porcelain_status": "",
"base_equivalent": False,
},
):
with self.assertRaises(RuntimeError) as ctx:
gitea_lock_issue(
issue_number=249,
branch_name="feat/issue-249-issue-lock-scratch-worktree",
remote="prgs",
worktree_path="/tmp/scratch/wt",
)
self.assertIn("base-equivalent", str(ctx.exception))
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_missing_lock_fails(self, _auth, _role):
if os.path.exists("/tmp/gitea_issue_lock.json"):
os.remove("/tmp/gitea_issue_lock.json")
if os.path.exists(ISSUE_LOCK_FILE):
os.remove(ISSUE_LOCK_FILE)
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
with self.assertRaises(RuntimeError) as ctx:
gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-mutations", remote="prgs")
@@ -2827,8 +3165,9 @@ class TestIssueLocking(unittest.TestCase):
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_branch_mismatch_fails(self, _auth, _role):
with open("/tmp/gitea_issue_lock.json", "w", encoding="utf-8") as f:
json.dump({"issue_number": 196, "branch_name": "feat/issue-196-mutations"}, f)
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(_sample_issue_lock(
issue_number=196, branch_name="feat/issue-196-mutations"), f)
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
with self.assertRaises(ValueError) as ctx:
gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-different", remote="prgs")
@@ -2838,8 +3177,9 @@ class TestIssueLocking(unittest.TestCase):
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_forbidden_terms_fails(self, _auth, _role):
with open("/tmp/gitea_issue_lock.json", "w", encoding="utf-8") as f:
json.dump({"issue_number": 196, "branch_name": "feat/issue-196-mutations"}, f)
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(_sample_issue_lock(
issue_number=196, branch_name="feat/issue-196-mutations"), f)
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
for term in ("equivalent to #196", "related to #196", "same as #196"):
with self.assertRaises(ValueError) as ctx:
@@ -2850,13 +3190,57 @@ class TestIssueLocking(unittest.TestCase):
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_missing_closes_ref_fails(self, _auth, _role):
with open("/tmp/gitea_issue_lock.json", "w", encoding="utf-8") as f:
json.dump({"issue_number": 196, "branch_name": "feat/issue-196-mutations"}, f)
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(_sample_issue_lock(
issue_number=196, branch_name="feat/issue-196-mutations"), f)
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
with self.assertRaises(ValueError) as ctx:
gitea_create_pr(title="feat: X refs #196", head="feat/issue-196-mutations", remote="prgs")
self.assertIn("must contain 'Closes #196' or 'Fixes #196' exactly", str(ctx.exception))
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_worktree_mismatch_fails(self, _auth, _role):
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-pr")
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(_sample_issue_lock(
issue_number=249,
branch_name="feat/issue-249-issue-lock-scratch-worktree",
worktree_path=scratch,
), f)
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
with self.assertRaises(ValueError) as ctx:
gitea_create_pr(
title="feat: lock scratch worktree Closes #249",
head="feat/issue-249-issue-lock-scratch-worktree",
remote="prgs",
worktree_path="/tmp/other-scratch",
)
self.assertIn("does not match locked worktree", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api):
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e")
mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"}
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(_sample_issue_lock(
issue_number=249,
branch_name="feat/issue-249-issue-lock-scratch-worktree",
worktree_path=scratch,
), f)
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
res = gitea_create_pr(
title="feat: issue-lock scratch worktree Closes #249",
head="feat/issue-249-issue-lock-scratch-worktree",
remote="prgs",
worktree_path=scratch,
)
self.assertEqual(res["number"], 250)
# ---------------------------------------------------------------------------
# Pre-flight ordering and workspace edit block (#210)
@@ -2872,6 +3256,12 @@ class TestPreflightVerification(unittest.TestCase):
self.orig_whoami_violation = mcp_server._preflight_whoami_violation
self.orig_capability_violation = mcp_server._preflight_capability_violation
self.orig_resolved_role = mcp_server._preflight_resolved_role
self.orig_process_start = mcp_server._process_start_porcelain
self.orig_whoami_baseline = mcp_server._preflight_whoami_baseline_porcelain
self.orig_capability_baseline = mcp_server._preflight_capability_baseline_porcelain
self.orig_whoami_files = mcp_server._preflight_whoami_violation_files
self.orig_capability_files = mcp_server._preflight_capability_violation_files
self.orig_reviewer_files = mcp_server._preflight_reviewer_violation_files
# Reset state for each test
mcp_server._preflight_whoami_called = False
@@ -2879,6 +3269,13 @@ class TestPreflightVerification(unittest.TestCase):
mcp_server._preflight_whoami_violation = False
mcp_server._preflight_capability_violation = False
mcp_server._preflight_resolved_role = None
mcp_server._process_start_porcelain = ""
mcp_server._preflight_whoami_baseline_porcelain = None
mcp_server._preflight_capability_baseline_porcelain = None
mcp_server._preflight_whoami_violation_files = []
mcp_server._preflight_capability_violation_files = []
mcp_server._preflight_reviewer_violation_files = []
os.environ["GITEA_TEST_PORCELAIN"] = ""
def tearDown(self):
# Restore real global variables
@@ -2888,30 +3285,47 @@ class TestPreflightVerification(unittest.TestCase):
mcp_server._preflight_whoami_violation = self.orig_whoami_violation
mcp_server._preflight_capability_violation = self.orig_capability_violation
mcp_server._preflight_resolved_role = self.orig_resolved_role
if "GITEA_TEST_FORCE_DIRTY" in os.environ:
del os.environ["GITEA_TEST_FORCE_DIRTY"]
mcp_server._process_start_porcelain = self.orig_process_start
mcp_server._preflight_whoami_baseline_porcelain = self.orig_whoami_baseline
mcp_server._preflight_capability_baseline_porcelain = self.orig_capability_baseline
mcp_server._preflight_whoami_violation_files = self.orig_whoami_files
mcp_server._preflight_capability_violation_files = self.orig_capability_files
mcp_server._preflight_reviewer_violation_files = self.orig_reviewer_files
for key in (
"GITEA_TEST_FORCE_DIRTY",
"GITEA_TEST_PORCELAIN",
"GITEA_ACTIVE_WORKTREE",
"GITEA_AUTHOR_WORKTREE",
):
if key in os.environ:
del os.environ[key]
def test_preflight_whoami_violation(self):
import mcp_server
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
mcp_server._preflight_capability_called = True
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_whoami_violation)
mcp_server.record_preflight_check("capability", resolved_role="author")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
self.assertIn("Workspace file edits occurred before gitea_whoami verification", str(ctx.exception))
self.assertIn("Offending files:", str(ctx.exception))
def test_preflight_capability_violation(self):
import mcp_server
mcp_server.record_preflight_check("whoami")
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
mcp_server._preflight_whoami_called = True
mcp_server.record_preflight_check("capability", resolved_role="author")
self.assertTrue(mcp_server._preflight_capability_violation)
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
self.assertIn("Workspace file edits occurred before gitea_resolve_task_capability verification", str(ctx.exception))
self.assertIn(
"Workspace file edits occurred before gitea_resolve_task_capability verification",
str(ctx.exception),
)
self.assertIn("Offending files:", str(ctx.exception))
def test_preflight_not_called_fails_closed(self):
import mcp_server
@@ -2927,16 +3341,80 @@ class TestPreflightVerification(unittest.TestCase):
def test_preflight_reviewer_mutation_violation(self):
import mcp_server
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "reviewer"
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="reviewer")
# When dirty, reviewer edits are blocked
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
# Session-owned reviewer edits after capability are blocked.
os.environ["GITEA_TEST_PORCELAIN"] = " M reviewer_edit.py\n"
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
self.assertIn("Reviewer profile is forbidden from modifying tracked workspace files", str(ctx.exception))
self.assertIn("reviewer_edit.py", str(ctx.exception))
# When clean, reviewer is allowed
del os.environ["GITEA_TEST_FORCE_DIRTY"]
# Foreign pre-existing dirty state does not block when unchanged.
os.environ["GITEA_TEST_PORCELAIN"] = ""
mcp_server.verify_preflight_purity()
def test_foreign_workspace_edits_do_not_block_clean_reviewer(self):
"""#252: concurrent-session dirt in the shared worktree is not attributed."""
import mcp_server
mcp_server._process_start_porcelain = " M foreign_author.py\n"
os.environ["GITEA_TEST_PORCELAIN"] = " M foreign_author.py\n"
mcp_server.record_preflight_check("whoami")
self.assertFalse(mcp_server._preflight_whoami_violation)
mcp_server.record_preflight_check("capability", resolved_role="reviewer")
self.assertFalse(mcp_server._preflight_capability_violation)
mcp_server.verify_preflight_purity()
def test_fresh_whoami_clears_sticky_violation(self):
"""#252: re-running whoami re-evaluates instead of replaying sticky state."""
import mcp_server
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_whoami_violation)
del os.environ["GITEA_TEST_FORCE_DIRTY"]
os.environ["GITEA_TEST_PORCELAIN"] = ""
mcp_server.record_preflight_check("whoami")
self.assertFalse(mcp_server._preflight_whoami_violation)
mcp_server.record_preflight_check("capability", resolved_role="reviewer")
mcp_server.verify_preflight_purity()
def test_runtime_context_matches_preflight_block(self):
"""#252: safe_next_action must not claim ready while pre-flight blocks."""
import mcp_server
status = mcp_server.assess_preflight_status()
self.assertFalse(status["preflight_ready"])
self.assertIn("gitea_whoami", status["preflight_block_reasons"][0])
def test_declared_clean_task_worktree_ignores_control_checkout_violation(self):
"""#275: active branches/ worktree is the inspected mutation workspace."""
import mcp_server
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_whoami_violation = True
mcp_server._preflight_whoami_violation_files = ["mcp_server.py"]
os.environ["GITEA_TEST_PORCELAIN"] = ""
worktree = "/repo/branches/issue-275-clean"
status = mcp_server.assess_preflight_status(worktree_path=worktree)
self.assertTrue(status["preflight_ready"])
self.assertEqual(status["preflight_block_reasons"], [])
self.assertIn("preflight_workspace", status)
mcp_server.verify_preflight_purity(worktree_path=worktree)
def test_declared_dirty_task_worktree_reports_workspace_diagnostics(self):
"""#275: dirty failures name the inspected task workspace, not just files."""
import mcp_server
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
os.environ["GITEA_TEST_PORCELAIN"] = " M task_file.py\n"
worktree = "/repo/branches/issue-275-dirty"
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(worktree_path=worktree)
msg = str(ctx.exception)
self.assertIn("active task workspace root", msg)
self.assertIn("inspected git root", msg)
self.assertIn("dirty files: task_file.py", msg)
self.assertIn("dirty scope:", msg)
+159
View File
@@ -0,0 +1,159 @@
"""Tests for merged PR cleanup reconciliation (#269)."""
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import merged_cleanup_reconcile as mcr # noqa: E402
class TestMergedCleanupAssessment(unittest.TestCase):
def test_extract_linked_issue_from_closes(self):
issue = mcr.extract_linked_issue(
"feat: cleanup (Closes #269)",
"No other refs",
)
self.assertEqual(issue, 269)
def test_merged_remote_branch_safe_when_gates_pass(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-269-merged-pr-cleanup-reconcile",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertTrue(result["safe_to_delete_remote"])
self.assertEqual(result["block_reasons"], [])
def test_open_pr_blocks_remote_delete(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
remote_branch_exists=True,
open_pr_heads={"feat/issue-9-example"},
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertFalse(result["safe_to_delete_remote"])
self.assertIn("open PR", result["block_reasons"][0])
def test_protected_branch_blocks_remote_delete(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=1,
head_branch="master",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertFalse(result["safe_to_delete_remote"])
self.assertIn("protected", result["block_reasons"][0])
def test_active_lock_blocks_remote_and_worktree_cleanup(self):
remote = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=True,
)
local = mcr.assess_local_worktree_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
worktree_state={
"exists": True,
"clean": True,
"current_branch": "feat/issue-9-example",
"worktree_path": "/repo/branches/feat-issue-9-example",
},
active_lock=True,
)
self.assertFalse(remote["safe_to_delete_remote"])
self.assertFalse(local["safe_to_remove_worktree"])
self.assertIn("active issue lock", remote["block_reasons"][0])
def test_dirty_worktree_blocks_local_cleanup(self):
result = mcr.assess_local_worktree_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
worktree_state={
"exists": True,
"clean": False,
"dirty_files": ["gitea_mcp_server.py"],
"current_branch": "feat/issue-9-example",
"worktree_path": "/repo/branches/feat-issue-9-example",
},
active_lock=False,
)
self.assertFalse(result["safe_to_remove_worktree"])
self.assertIn("tracked edits", result["block_reasons"][0])
class TestMergedCleanupReport(unittest.TestCase):
def test_build_report_skips_unmerged_closed_prs(self):
with tempfile.TemporaryDirectory() as tmp:
report = mcr.build_reconciliation_report(
project_root=tmp,
closed_prs=[
{
"number": 10,
"title": "closed not merged",
"body": "Closes #10",
"head": {"ref": "feat/issue-10-x"},
"merged_at": None,
},
{
"number": 11,
"title": "merged",
"body": "Closes #11",
"head": {"ref": "feat/issue-11-x"},
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "abc123",
},
],
open_prs=[],
remote_branch_exists={"feat/issue-11-x": True},
head_on_master={11: True},
delete_capability_allowed=False,
)
self.assertEqual(report["merged_pr_count"], 1)
entry = report["entries"][0]
self.assertEqual(entry["pr_number"], 11)
self.assertEqual(entry["issue_number"], 11)
self.assertFalse(entry["remote_branch"]["safe_to_delete_remote"])
self.assertIn("delete_branch capability", entry["remote_branch"]["block_reasons"][0])
def test_has_active_issue_lock_reads_lock_file(self):
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle:
handle.write('{"branch_name": "feat/issue-9-example"}')
lock_path = handle.name
try:
self.assertTrue(
mcr.has_active_issue_lock("feat/issue-9-example", lock_path=lock_path)
)
self.assertFalse(
mcr.has_active_issue_lock("feat/other-branch", lock_path=lock_path)
)
finally:
os.remove(lock_path)
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
"""Tests for final-report mutation ledger verifier (#331)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_mutation_ledger_report # noqa: E402
class TestMutationLedgerReport(unittest.TestCase):
def test_none_claim_with_performed_edit_blocks(self):
report = (
"Review decision: request_changes.\n"
"File edits by reviewer: none\n"
)
action_log = [
{"action": "Edited", "path": "walkthrough.md", "tracked": False},
]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertFalse(result["proven"])
self.assertTrue(result["file_edits_claimed_none"])
def test_reported_edit_passes(self):
report = (
"File edits by reviewer: Edited walkthrough.md (untracked)\n"
"Mutation ledger: Edited walkthrough.md untracked in review worktree\n"
)
action_log = [
{
"action": "Edited",
"path": "walkthrough.md",
"tracked": False,
"in_repo": True,
},
]
result = assess_mutation_ledger_report(
report,
action_log=action_log,
walkthrough_explicitly_requested=True,
)
self.assertTrue(result["proven"])
def test_unreported_mutation_blocks(self):
report = "File edits by reviewer: none\n"
action_log = [{"action": "Wrote", "path": "/tmp/review-notes.txt"}]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertFalse(result["proven"])
self.assertIn("/tmp/review-notes.txt", result["unreported_paths"])
def test_outside_repo_requires_label(self):
report = (
"File edits by reviewer: Wrote /tmp/review-notes.txt\n"
)
action_log = [
{
"action": "Wrote",
"path": "/tmp/review-notes.txt",
"outside_repo": True,
},
]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertFalse(result["proven"])
self.assertIn("outside repo", " ".join(result["reasons"]).lower())
def test_gated_rejection_excluded_from_performed(self):
report = "File edits by reviewer: none\nRejected gated calls: submit_pr_review blocked\n"
action_log = [
{
"action": "Edited",
"path": "walkthrough.md",
"performed": False,
"gated_rejected": True,
},
]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertTrue(result["proven"])
self.assertEqual(result["performed_mutations"], [])
def test_post_status_artifact_requires_final_git_status(self):
report = (
"File edits by reviewer: Created scratch/notes.md (untracked)\n"
)
action_log = [
{
"action": "Created",
"path": "scratch/notes.md",
"tracked": False,
"after_git_status": True,
},
]
result = assess_mutation_ledger_report(
report,
action_log=action_log,
final_git_status_reported=False,
)
self.assertFalse(result["proven"])
self.assertIn("final git status", " ".join(result["reasons"]).lower())
if __name__ == "__main__":
unittest.main()
+181
View File
@@ -0,0 +1,181 @@
"""Tests for operation-scoped role selection and automatic dispatch switching (#228)."""
import os
import sys
import json
import tempfile
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_config
import gitea_auth
import mcp_server
from reviewer_worktree import assess_author_worktree_continuity
CONFIG_TEST = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {
"enabled": True,
"base_url": "https://gitea.example.com"
}
}
},
"profiles": {
"author-profile": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"execution_profile": "author-profile"
},
"reviewer-profile": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "reviewer-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
"execution_profile": "reviewer-profile"
}
}
}
class TestOperationScopedRoles(unittest.TestCase):
def setUp(self):
self._remotes_patch = patch.dict(mcp_server.REMOTES, {
"dadeschools": {"host": "gitea.example.com", "org": "Example-Org", "repo": "Example-Repo"},
"prgs": {"host": "gitea.example.com", "org": "Example-Org", "repo": "Example-Repo"}
})
self._remotes_patch.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
self._write_config(CONFIG_TEST)
def tearDown(self):
self._remotes_patch.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
self._dir.cleanup()
def _write_config(self, obj):
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(obj))
def _env(self, profile="author-profile", with_reviewer_token=True):
env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
}
if with_reviewer_token:
env["GITEA_TOKEN_REVIEWER"] = "reviewer-pass"
return env
@patch("mcp_server.api_request")
def test_auto_switch_to_reviewer(self, mock_api):
# mock identity resolution to return username matching profile
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("author-profile")):
# initially we are author-profile
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
self.assertEqual(mcp_server.get_profile()["profile_name"], "author-profile")
# resolve a reviewer task (review_pr)
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
# verify it automatically switched to reviewer-profile
self.assertTrue(res["allowed_in_current_session"])
self.assertTrue(res["available_in_session"])
self.assertEqual(res["active_profile"], "reviewer-profile")
self.assertEqual(res["active_identity"], "reviewer-user")
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
@patch("mcp_server.api_request")
def test_auto_switch_to_author(self, mock_api):
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("reviewer-profile")):
# initially we are reviewer-profile
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
# resolve an author task (create_issue)
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
# verify it automatically switched to author-profile
self.assertTrue(res["allowed_in_current_session"])
self.assertTrue(res["available_in_session"])
self.assertEqual(res["active_profile"], "author-profile")
self.assertEqual(res["active_identity"], "author-user")
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
@patch("mcp_server.api_request")
def test_restart_required_when_unattached(self, mock_api):
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
# launch without reviewer token in env
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
# resolve a reviewer task (review_pr)
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
# verify it did NOT switch and reports restart_required
self.assertFalse(res["allowed_in_current_session"])
self.assertFalse(res["available_in_session"])
self.assertTrue(res["configured"])
self.assertTrue(res["restart_required"])
self.assertTrue(res["stop_required"])
self.assertIn("Reviewer profile exists but MCP server was added after session startup and is not attached", res["reason"])
def test_author_continuity_dirty_worktree(self):
# author is allowed to keep dirty worktree
res = assess_author_worktree_continuity({
"task_role": "author",
"dirty_files": ["review_proofs.py"],
})
self.assertTrue(res["allowed"])
# reviewer is blocked by reviewer worktree proof
res2 = assess_author_worktree_continuity({
"task_role": "reviewer",
"worktree_path": "/repo",
"dirty_files": ["review_proofs.py"],
"pr_scope_files": ["docs/wiki/Repositories.md"],
"scratch_used": False,
})
self.assertFalse(res2["proven"])
@patch("mcp_server.api_request")
def test_mutating_actions_auto_switch(self, mock_api):
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("author-profile")):
# verify we are author-profile
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
# call a reviewer mutation check helper (like _profile_permission_block with reviewer permission)
blocked = mcp_server._profile_permission_block("gitea.pr.merge", remote="prgs")
self.assertIsNone(blocked) # should switch to reviewer-profile and allow it (no permission block)
# verify we dynamically switched to reviewer-profile
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
if __name__ == "__main__":
unittest.main()
+4 -1
View File
@@ -132,7 +132,10 @@ class TestControlPlaneGuide(GuideTestBase):
rules = g["rules"]
for key in ("hard_stops", "fail_closed", "head_sha_pinning",
"merge_confirmation", "redaction", "separation",
"profile_switching", "identity_verification"):
"profile_switching", "identity_verification",
"work_selection", "global_worktree",
"shell_spawn_hard_stop", "subagent_tool_budget",
"subagent_delegation"):
self.assertIn(key, rules)
self.assertIn("MERGE PR", json.dumps(rules["merge_confirmation"]))
self.assertTrue(rules["hard_stops"])
+149 -54
View File
@@ -16,38 +16,50 @@ import gitea_config
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
def _final_page_fetch(items):
return (items, {
"page": 1,
"per_page": 50,
"returned_count": len(items),
"has_more": False,
"next_page": None,
"is_final_page": True,
})
class TestPRQueueInventory(unittest.TestCase):
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profile_can_list_open_prs(self, mock_get_profile, _auth, mock_get_all):
def test_author_profile_can_list_open_prs(self, mock_get_profile, _auth, mock_fetch):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read", "gitea.pr.comment"],
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = [
mock_fetch.return_value = _final_page_fetch([
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True}
]
])
result = gitea_list_prs(state="open", remote="prgs")
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["number"], 1)
self.assertEqual(len(result["prs"]), 1)
self.assertEqual(result["prs"][0]["number"], 1)
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
@patch("gitea_config.is_runtime_switching_enabled", return_value=True)
def test_author_profile_can_produce_pending_reviewer_queue_report(self, mock_switch, mock_get_profile, _auth, mock_api, mock_get_all):
def test_author_profile_can_produce_pending_reviewer_queue_report(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = [
mock_fetch.return_value = _final_page_fetch([
{
"number": 1,
"title": "PR 1",
@@ -72,7 +84,7 @@ class TestPRQueueInventory(unittest.TestCase):
"body": "",
"updated_at": "2026-07-05T11:00:00Z"
}
]
])
mock_api.return_value = {"login": "jcwalker3"} # whoami
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
@@ -86,18 +98,18 @@ class TestPRQueueInventory(unittest.TestCase):
# updated_at surfaced for reconciliation (#166)
self.assertIn("Updated: 2026-07-05T10:00:00Z", result["message"])
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profile_never_calls_mutation_tools_during_inventory(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_author_profile_never_calls_mutation_tools_during_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = []
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
@@ -105,26 +117,42 @@ class TestPRQueueInventory(unittest.TestCase):
method = call.args[0]
self.assertEqual(method, "GET")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_reviewer_profile_can_proceed_from_inventory_to_review_gates(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_reviewer_profile_can_proceed_from_inventory_to_review_gates(self, mock_get_profile, _auth, mock_api, mock_fetch):
mock_get_profile.return_value = {
"profile_name": "gitea-reviewer",
"allowed_operations": ["read", "approve", "review"],
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = [
mock_fetch.return_value = _final_page_fetch([
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}}
]
# mock_api: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility), 4) /pulls/1/reviews (POST review)
])
# mock_api: inventory whoami, eligibility whoami, eligibility PR,
# POST review (#244: state + visible-verdict GET reviews).
mock_api.side_effect = [
{"login": "reviewer1"}, # inventory whoami
{"login": "reviewer1"}, # submit eligibility whoami
{"user": {"login": "other_user"}, "state": "open", "head": {"sha": "abc1"}, "mergeable": True}, # submit eligibility PR
{"id": 100}, # POST review
{"login": "reviewer1"},
{"login": "reviewer1"},
{
"user": {"login": "other_user"},
"state": "open",
"head": {"sha": "abc1"},
"mergeable": True,
},
{"id": 100, "state": "APPROVED"},
[
{
"id": 100,
"user": {"login": "reviewer1"},
"state": "APPROVED",
"commit_id": "abc1",
"submitted_at": "2026-07-06T10:00:00Z",
"dismissed": False,
}
],
]
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
@@ -136,19 +164,19 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIn("Repository:", result["message"])
self.assertIn("Successfully submitted review", result["message"])
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
@patch("gitea_config.is_runtime_switching_enabled", return_value=False)
def test_static_runtime_rejects_or_hides_profile_activation(self, mock_switch, mock_get_profile, _auth, mock_api, mock_get_all):
def test_static_runtime_rejects_or_hides_profile_activation(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = []
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
@@ -184,11 +212,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertFalse(result["eligible"])
self.assertEqual(mock_api.call_count, 0)
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_inventory_failure_output_is_redacted_no_raw_exception_leak(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_inventory_failure_output_is_redacted_no_raw_exception_leak(self, mock_get_profile, _auth, mock_api, mock_fetch):
"""Inventory failure path must use redaction and not leak raw exception text."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -197,7 +225,7 @@ class TestPRQueueInventory(unittest.TestCase):
"base_url": None,
}
# Exception contains something that should be redacted or not leaked raw
mock_get_all.side_effect = Exception("connection error token supersecrettoken123 at https://internal.example/repo")
mock_fetch.side_effect = Exception("connection error token supersecrettoken123 at https://internal.example/repo")
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs")
@@ -212,11 +240,11 @@ class TestPRQueueInventory(unittest.TestCase):
# uses project's redaction pattern (token prefix -> [REDACTED])
# even if url leaks, the exception is redacted per pattern
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_inventory_failure_output_redacts_real_service_urls(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_inventory_failure_output_redacts_real_service_urls(self, mock_get_profile, _auth, mock_api, mock_fetch):
"""Failure paths must fully redact real service hostnames and URLs from inventory output."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -224,7 +252,7 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.side_effect = Exception("failed to connect to https://gitea.prgs.cc/api/v1/repos/x")
mock_fetch.side_effect = Exception("failed to connect to https://gitea.prgs.cc/api/v1/repos/x")
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs")
@@ -234,11 +262,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIn("[REDACTED_URL]", msg)
self.assertNotIn("gitea.prgs.cc", msg)
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profiles_can_perform_read_only_pr_inventory(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_author_profiles_can_perform_read_only_pr_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch):
"""Author profiles with read can perform read-only PR inventory (report produced, no mutations)."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -246,9 +274,9 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = [
mock_fetch.return_value = _final_page_fetch([
{"number": 7, "title": "Some PR", "state": "open", "head": {"ref": "f", "sha": "def"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "someone"}, "labels": [], "body": ""}
]
])
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=7, event="APPROVE", remote="prgs")
@@ -261,12 +289,79 @@ class TestPRQueueInventory(unittest.TestCase):
for call in mock_api.call_args_list:
self.assertEqual(call.args[0], "GET")
@patch("mcp_server.api_get_all")
@patch("mcp_server._local_git_remote_url")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profiles_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_empty_inventory_runs_trust_gate_and_blocks_without_trusted_empty(
self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url
):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
mock_local_url.return_value = None
result = gitea_review_pr(
pr_number=1,
event="APPROVE",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertFalse(result["success"])
msg = result["message"]
self.assertIn("pr_inventory_trust_gate.status: untrusted_empty", msg)
self.assertIn("Empty-queue claim blocked", msg)
self.assertNotIn("Open PRs found: 0 (trusted_empty)", msg)
@patch("mcp_server._local_git_remote_url")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_empty_inventory_trusted_empty_when_gate_passes(
self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url
):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
mock_local_url.return_value = (
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
)
result = gitea_review_pr(
pr_number=1,
event="APPROVE",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertFalse(result["success"])
msg = result["message"]
self.assertIn("pr_inventory_trust_gate.status: trusted_empty", msg)
self.assertIn("Open PRs found: 0 (trusted_empty)", msg)
@patch("mcp_server._local_git_remote_url")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profiles_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url):
"""Author profiles still cannot approve, request_changes, merge, or bypass gates even with inventory."""
mock_local_url.return_value = (
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
)
for event in ["APPROVE", "REQUEST_CHANGES"]:
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -274,7 +369,7 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = []
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=1, event=event, remote="prgs")
@@ -294,10 +389,10 @@ class TestPRQueueInventory(unittest.TestCase):
# Tests use direct list/view calls + inventory path to ensure fields and
# live data are available for detection without trusting prior handoffs.
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_r5_s1_stale_prior_handoff_merged_but_live_pr_open(self, mock_get_profile, _auth, mock_get_all):
def test_r5_s1_stale_prior_handoff_merged_but_live_pr_open(self, mock_get_profile, _auth, mock_fetch):
"""Scenario 1: stale prior handoff says merged but live PR list says open."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -305,17 +400,17 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = [{
mock_fetch.return_value = _final_page_fetch([{
"number": 99, "title": "Stale merge claim", "state": "open",
"head": {"ref": "f99", "sha": "sha99"}, "base": {"ref": "master"},
"mergeable": True, "user": {"login": "other"},
"labels": [], "body": "Fixes #200",
"updated_at": "2026-07-05T22:00:00Z"
}]
}])
prs = gitea_list_prs(state="open", remote="prgs")
self.assertEqual(len(prs), 1)
self.assertEqual(prs[0]["state"], "open")
self.assertIn("updated_at", prs[0]) # enables staleness check vs prior "merged"
self.assertEqual(len(prs["prs"]), 1)
self.assertEqual(prs["prs"][0]["state"], "open")
self.assertIn("updated_at", prs["prs"][0]) # enables staleness check vs prior "merged"
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -341,11 +436,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIsNotNone(pr.get("closed_at"))
# live closed would contradict prior "open" handoff
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_r5_s3_list_prs_and_view_pr_disagree(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_r5_s3_list_prs_and_view_pr_disagree(self, mock_get_profile, _auth, mock_api, mock_fetch):
"""Scenario 3: gitea_list_prs and gitea_view_pr disagree on state/details."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -354,12 +449,12 @@ class TestPRQueueInventory(unittest.TestCase):
"base_url": None,
}
# list says open
mock_get_all.return_value = [{
mock_fetch.return_value = _final_page_fetch([{
"number": 77, "title": "Disagree", "state": "open",
"head": {"ref": "f77", "sha": "s77"}, "base": {"ref": "master"},
"mergeable": True, "user": {"login": "x"},
"labels": [], "body": "", "updated_at": "2026-07-05T23:00:00Z"
}]
}])
# view says closed (simulating inconsistency)
mock_api.return_value = {
"number": 77, "title": "Disagree", "state": "closed",
@@ -370,7 +465,7 @@ class TestPRQueueInventory(unittest.TestCase):
}
listed = gitea_list_prs(state="open", remote="prgs")
viewed = gitea_view_pr(pr_number=77, remote="prgs")
self.assertEqual(listed[0]["state"], "open")
self.assertEqual(listed["prs"][0]["state"], "open")
self.assertEqual(viewed["state"], "closed")
# caller must reconcile; mismatch is hard blocker per rules
@@ -400,11 +495,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIsNone(pr.get("merge_commit_sha"))
# post-merge view must surface missing commit for detection
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_r5_s5_linked_issue_remains_open_after_claimed_merge(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_r5_s5_linked_issue_remains_open_after_claimed_merge(self, mock_get_profile, _auth, mock_api, mock_fetch):
"""Scenario 5: linked issue remains open after claimed merge."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -413,12 +508,12 @@ class TestPRQueueInventory(unittest.TestCase):
"base_url": None,
}
# PR claims merge, links #300
mock_get_all.return_value = [{
mock_fetch.return_value = _final_page_fetch([{
"number": 300, "title": "PR for #300", "state": "closed",
"head": {"ref": "f300", "sha": "s3"}, "base": {"ref": "master"},
"mergeable": True, "user": {"login": "u"},
"labels": [], "body": "Fixes #300", "updated_at": "2026-07-05T20:20:00Z"
}]
}])
# But linked issue (via list_issues or view) still open - here we use view_pr body to confirm link
# For this test we assert the PR data shows link, caller must check issue state live
mock_api.return_value = {
+93
View File
@@ -0,0 +1,93 @@
"""Doc-contract checks for proof wording enforcement (#330)."""
import unittest
from review_proofs import assess_proof_wording, build_final_report
class TestProofWording(unittest.TestCase):
def test_rejects_inventory_complete_without_pagination_proof(self):
result = assess_proof_wording(
"Open PR inventory is complete because only 3 PRs were returned."
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
def test_rejects_default_page_size_assumption(self):
result = assess_proof_wording(
"Inventory exhaustive: fewer than the default Gitea page size returned."
)
self.assertFalse(result["proven"])
self.assertIn("default_page_size_assumption", result["flagged_rules"])
def test_allows_inventory_complete_with_finality_metadata(self):
result = assess_proof_wording(
"Inventory complete. pagination_complete: true, is_final_page: true"
)
self.assertTrue(result["proven"])
def test_allows_inventory_complete_with_session_evidence(self):
result = assess_proof_wording(
"Queue inventory complete.",
session_evidence={"pagination_complete": True},
)
self.assertTrue(result["proven"])
def test_rejects_live_proof_without_session_evidence(self):
result = assess_proof_wording("Live proof confirms mergeable state.")
self.assertFalse(result["proven"])
self.assertIn("live_proof", result["flagged_rules"])
def test_allows_prior_blocker_reuse_wording(self):
result = assess_proof_wording(
"Prior blocker reused; head SHA unchanged since blocking review. "
"Live proof not claimed for this skip."
)
self.assertTrue(result["proven"])
def test_allows_live_proof_with_current_session_evidence(self):
result = assess_proof_wording(
"Live proof: gitea_view_pr revalidated in the current session."
)
self.assertTrue(result["proven"])
def test_rejects_same_as_master_without_baseline_proof(self):
result = assess_proof_wording("Diff is same as master.")
self.assertFalse(result["proven"])
self.assertIn("same_as_master", result["flagged_rules"])
def test_allows_same_as_master_with_baseline_wording(self):
result = assess_proof_wording(
"Clean baseline worktree proof: diff base matches master."
)
self.assertTrue(result["proven"])
def test_rejects_file_edits_none_without_ledger(self):
result = assess_proof_wording("Workspace mutations: file edits none.")
self.assertFalse(result["proven"])
self.assertIn("no_file_edits", result["flagged_rules"])
def test_allows_file_edits_none_with_mutation_ledger(self):
result = assess_proof_wording(
"Mutation ledger: tracked file edits: none",
session_evidence={"mutation_ledger_clean": True},
)
self.assertTrue(result["proven"])
def test_build_final_report_downgrades_on_proof_wording_violation(self):
report = build_final_report(
{"proven": True},
{"complete": True},
{"claimable": True, "verdict": "strong"},
{"status": "clean"},
True,
False,
True,
report_text="Inventory complete after listing 4 open PRs.",
)
self.assertEqual(report["grade"], "downgraded")
self.assertFalse(report["proof_wording_proven"])
self.assertTrue(report["proof_wording_violations"])
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -0,0 +1,91 @@
"""Doc-contract checks for queue-status report verifier (#339)."""
import unittest
from review_proofs import assess_queue_status_report, build_final_report
BAD_QUEUE_STATUS_REPORT = """
## PR Queue Status
Loaded workflows/review-merge-pr.md. Inventory complete because gitea_list_prs
returned 6 open PRs, fewer than the default Gitea page size.
PR #286 skipped: prior diagnostic worktree simulation showed merge conflicts.
Live blocker proof for all skipped PRs.
All open PRs are conflicted or blocked. Blockers: none.
## Controller Handoff
- Task: review queue status
- Repo: Scaled-Tech-Consulting/Gitea-Tools
- Role: reviewer
- Identity: reviewer1 / prgs-reviewer
- Selected PR: none
- Inventory pagination proof: assumed complete (6 < 50)
- Already-landed gate: passed
- Author-safety result: passed
- Merge preflight: passed
- Review worktree used: false
- Review worktree dirty before validation: false
- Blockers: none
- Current status: queue inspected; no PR selected
"""
class TestQueueStatusReport(unittest.TestCase):
def test_bad_fixture_fails_closed(self):
result = assess_queue_status_report(BAD_QUEUE_STATUS_REPORT)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(result["queue_status_only"])
joined = " ".join(result["violations"]).lower()
self.assertIn("already-landed gate", joined)
self.assertIn("author-safety", joined)
self.assertIn("merge preflight", joined)
self.assertIn("prior diagnostic", joined)
self.assertIn("blockers", joined)
def test_passes_with_not_applicable_gates(self):
report = """
## Controller Handoff
- Selected PR: none
- Inventory pagination proof: is_final_page: true, has_more: false
- Already-landed gate: not applicable
- Author-safety result: not run
- Merge preflight: not applicable
- Review worktree used: false
- Review worktree dirty before validation: not applicable
- Blockers: all PRs skipped pending reviewer
"""
result = assess_queue_status_report(report)
self.assertTrue(result["proven"])
def test_rejects_passed_gates_without_selected_pr(self):
report = """
## Controller Handoff
- Selected PR: none
- Already-landed gate: passed
- Blockers: PR #1 conflicted
"""
result = assess_queue_status_report(report)
self.assertFalse(result["proven"])
self.assertIn("already-landed gate", " ".join(result["violations"]).lower())
def test_build_final_report_downgrades_bad_queue_status(self):
report = build_final_report(
{"proven": True},
{"complete": True},
{"claimable": True, "verdict": "strong"},
{"status": "clean"},
True,
False,
True,
report_text=BAD_QUEUE_STATUS_REPORT,
)
self.assertEqual(report["grade"], "downgraded")
self.assertFalse(report["queue_status_report_proven"])
self.assertTrue(report["queue_status_violations"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,150 @@
"""Tests for reconciliation PR-inventory pagination proof (#308).
Already-landed reconciliation reports may not claim a complete queue
scan (or that all already-landed PRs were found) unless the report
carries pagination proof fields and the per-page evidence proves the
final page was reached or the page limit was not hit.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_reconciliation_inventory_proof # noqa: E402
def _report(claim=True, fields=True, pages="1", counts="12",
final_proof="is_final_page=true, has_more=false"):
lines = []
if claim:
lines.append(
"Inventory scope: complete queue scan; all already-landed "
"PRs were found."
)
if fields:
lines += [
"- Requested page size: 50",
f"- Pages fetched: {pages}",
f"- Returned PR count per page: {counts}",
f"- Final page proof: {final_proof}",
]
return "\n".join(lines) + "\n"
def _page(page, returned, per_page=50, has_more=False, final=True,
inventory_complete=None):
d = {
"page": page,
"per_page": per_page,
"returned_count": returned,
"has_more": has_more,
"is_final_page": final,
}
if inventory_complete is not None:
d["inventory_complete"] = inventory_complete
return d
class TestCompleteScanProven(unittest.TestCase):
def test_first_page_below_limit_passes(self):
result = assess_reconciliation_inventory_proof(
_report(),
pagination_evidence=[_page(1, 12)],
)
self.assertTrue(result["complete"])
self.assertEqual(result["reasons"], [])
def test_multiple_pages_to_final_passes(self):
report = _report(
pages="3", counts="50, 50, 12",
final_proof="has_more=false on page 3",
)
evidence = [
_page(1, 50, has_more=True, final=False),
_page(2, 50, has_more=True, final=False),
_page(3, 12),
]
result = assess_reconciliation_inventory_proof(
report, pagination_evidence=evidence
)
self.assertTrue(result["complete"])
def test_no_completeness_claim_needs_no_proof(self):
result = assess_reconciliation_inventory_proof(
_report(claim=False, fields=False),
pagination_evidence=None,
)
self.assertTrue(result["complete"])
class TestUnprovenScanFails(unittest.TestCase):
def test_first_page_exactly_at_limit_fails(self):
# 50 returned with per_page 50 and no final-page signal: the page
# limit was hit, so completeness is unproven.
evidence = [_page(1, 50, has_more=True, final=False)]
result = assess_reconciliation_inventory_proof(
_report(counts="50", final_proof="none"),
pagination_evidence=evidence,
)
self.assertFalse(result["complete"])
self.assertTrue(
any("final page" in r.lower() for r in result["reasons"])
)
def test_missing_pagination_evidence_fails(self):
result = assess_reconciliation_inventory_proof(
_report(), pagination_evidence=None
)
self.assertFalse(result["complete"])
self.assertTrue(
any("pagination" in r.lower() for r in result["reasons"])
)
def test_missing_report_fields_fails(self):
result = assess_reconciliation_inventory_proof(
_report(fields=False),
pagination_evidence=[_page(1, 12)],
)
self.assertFalse(result["complete"])
self.assertTrue(
any("requested page size" in r.lower()
for r in result["reasons"])
)
def test_page_count_mismatch_fails(self):
# Report claims 1 page; evidence shows 2 were fetched.
evidence = [
_page(1, 50, has_more=True, final=False),
_page(2, 3),
]
result = assess_reconciliation_inventory_proof(
_report(pages="1", counts="50"),
pagination_evidence=evidence,
)
self.assertFalse(result["complete"])
self.assertTrue(
any("pages fetched" in r.lower() for r in result["reasons"])
)
def test_per_page_count_mismatch_fails(self):
evidence = [_page(1, 12)]
result = assess_reconciliation_inventory_proof(
_report(counts="11"),
pagination_evidence=evidence,
)
self.assertFalse(result["complete"])
self.assertTrue(
any("count per page" in r.lower() for r in result["reasons"])
)
def test_evidence_page_missing_metadata_fails(self):
result = assess_reconciliation_inventory_proof(
_report(),
pagination_evidence=[{"page": 1}],
)
self.assertFalse(result["complete"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,104 @@
"""Tests for REQUEST_CHANGES override proof before approval (#326)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_request_changes_approval_proof # noqa: E402
HEAD_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
HEAD_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
def _feedback(**overrides):
base = {
"success": True,
"current_head_sha": HEAD_A,
"has_blocking_change_requests": False,
"author_pushed_after_request_changes": False,
"reviews": [],
}
base.update(overrides)
return base
def _blocking_review(**overrides):
entry = {
"reviewer": "reviewer-a",
"verdict": "REQUEST_CHANGES",
"body": "Tests fail on pinned head; fix test_commit_files_gate.",
"submitted_at": "2026-07-07T01:00:00-05:00",
"reviewed_head_sha": HEAD_A,
"dismissed": False,
"stale": False,
}
entry.update(overrides)
return entry
class TestRequestChangesApprovalProof(unittest.TestCase):
def test_missing_feedback_blocks_approval(self):
result = assess_request_changes_approval_proof(None)
self.assertFalse(result["approve_allowed"])
self.assertTrue(result["block"])
def test_no_prior_request_changes_allows_approval(self):
feedback = _feedback(
reviews=[{
"reviewer": "sysadmin",
"verdict": "APPROVED",
"body": "LGTM",
"submitted_at": "2026-07-07T02:00:00-05:00",
"reviewed_head_sha": HEAD_A,
"dismissed": False,
}]
)
result = assess_request_changes_approval_proof(feedback)
self.assertTrue(result["approve_allowed"])
self.assertIsNone(result["blocking_review"])
def test_changed_head_since_blocker_allows_approval(self):
feedback = _feedback(
current_head_sha=HEAD_B,
author_pushed_after_request_changes=True,
has_blocking_change_requests=True,
reviews=[_blocking_review(reviewed_head_sha=HEAD_A)],
)
result = assess_request_changes_approval_proof(feedback)
self.assertTrue(result["approve_allowed"])
self.assertTrue(result["head_changed_since_blocker"])
def test_unchanged_head_without_override_blocks_approval(self):
feedback = _feedback(
has_blocking_change_requests=True,
reviews=[_blocking_review()],
)
result = assess_request_changes_approval_proof(feedback)
self.assertFalse(result["approve_allowed"])
self.assertIn("override_reason", " ".join(result["reasons"]))
def test_unchanged_head_with_valid_override_allows_approval(self):
blocker = _blocking_review()
feedback = _feedback(
has_blocking_change_requests=True,
reviews=[blocker],
)
report = (
"Blocker text: Tests fail on pinned head; fix test_commit_files_gate.\n"
"Override: wrong_validation_environment — CI used stale worktree."
)
result = assess_request_changes_approval_proof(
feedback,
override_reason="wrong_validation_environment",
override_explanation="CI used stale worktree; local rerun passed.",
report_text=report,
)
self.assertTrue(result["approve_allowed"])
self.assertEqual(
result["blocking_review"]["blocking_reviewer"], "reviewer-a"
)
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -141,6 +141,17 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertTrue(res["allowed_in_current_session"])
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_lock_issue_author_profile_allowed(self, _auth, _api):
# #275: lock_issue must be an exact resolver task for claim/lock gates.
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_resolve_task_capability(task="lock_issue", remote="prgs")
self.assertEqual(res["requested_task"], "lock_issue")
self.assertEqual(res["required_operation_permission"], "gitea.issue.comment")
self.assertTrue(res["allowed_in_current_session"])
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
def test_resolve_unknown_task_fails_closed(self):
with patch.dict(os.environ, self._env("author-profile")):
with self.assertRaises(ValueError):
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
"""Tests for prior-blocker skip proof verifier (#318)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from reviewer_blocker_skip import assess_prior_blocker_skip_proof # noqa: E402
HEAD_CURRENT = "0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
HEAD_BLOCKER = "1111111111111111111111111111111111111111"
def _good_blocker_skip_report(pr_number: int) -> str:
return "\n".join([
f"Skipped PR #{pr_number} due to prior REQUEST_CHANGES.",
f"- PR number: #{pr_number}",
f"- Current head SHA: {HEAD_CURRENT}",
"- Blocking review decision: request_changes",
f"- Blocking review head SHA: {HEAD_CURRENT}",
"- Head unchanged since blocker",
"- Reason remains blocked: unresolved review feedback at same head",
"- Blocker revalidated live in this session via gitea_get_pr_review_feedback",
])
class TestPriorBlockerSkipProof(unittest.TestCase):
def test_no_skips_passes(self):
result = assess_prior_blocker_skip_proof(
"Selected PR #281 for review.",
skipped_prs=[],
)
self.assertTrue(result["proven"])
def test_blocked_unchanged_with_live_proof_passes(self):
report = _good_blocker_skip_report(280)
result = assess_prior_blocker_skip_proof(
report,
skipped_prs=[{
"pr_number": 280,
"head_sha": HEAD_CURRENT,
"blocking_decision": "request_changes",
"blocking_review_head_sha": HEAD_CURRENT,
"head_changed_since_blocker": False,
"blocker_verified": True,
}],
)
self.assertTrue(result["proven"])
def test_head_changed_after_blocker_blocks_skip(self):
result = assess_prior_blocker_skip_proof(
_good_blocker_skip_report(280),
skipped_prs=[{
"pr_number": 280,
"head_sha": HEAD_CURRENT,
"blocking_decision": "request_changes",
"blocking_review_head_sha": HEAD_BLOCKER,
"head_changed_since_blocker": True,
"blocker_verified": True,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(any("head changed" in r for r in result["reasons"]))
def test_missing_live_proof_blocks(self):
report = _good_blocker_skip_report(280).replace(
"- Blocker revalidated live in this session via gitea_get_pr_review_feedback",
"",
)
result = assess_prior_blocker_skip_proof(
report,
skipped_prs=[{
"pr_number": 280,
"head_sha": HEAD_CURRENT,
"blocking_decision": "request_changes",
"blocking_review_head_sha": HEAD_CURRENT,
"head_changed_since_blocker": False,
"blocker_verified": True,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(any("live blocker proof" in r for r in result["reasons"]))
def test_blocker_unverified_classification_passes(self):
report = "\n".join([
"Skipped PR #280.",
"Classification: BLOCKER_STATUS_UNVERIFIED",
"Review feedback could not be fetched live in this session.",
])
result = assess_prior_blocker_skip_proof(
report,
skipped_prs=[{
"pr_number": 280,
"blocking_decision": "request_changes",
"blocker_verified": False,
}],
)
self.assertTrue(result["proven"])
def test_unverified_without_classification_blocks(self):
result = assess_prior_blocker_skip_proof(
"Skipped PR #280 based on memory from last session.",
skipped_prs=[{
"pr_number": 280,
"blocking_decision": "request_changes",
"blocker_verified": False,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(any("BLOCKER_STATUS_UNVERIFIED" in r for r in result["reasons"]))
def test_missing_blocking_head_sha_blocks(self):
report = _good_blocker_skip_report(280).replace(
f"- Blocking review head SHA: {HEAD_CURRENT}",
"",
)
result = assess_prior_blocker_skip_proof(
report,
skipped_prs=[{
"pr_number": 280,
"head_sha": HEAD_CURRENT,
"blocking_decision": "request_changes",
"blocking_review_head_sha": HEAD_CURRENT,
"head_changed_since_blocker": False,
"blocker_verified": True,
}],
)
self.assertFalse(result["proven"])
class TestExport(unittest.TestCase):
def test_review_proofs_reexport(self):
from review_proofs import assess_prior_blocker_skip_proof as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
+112
View File
@@ -0,0 +1,112 @@
"""Tests for reviewer local-fallback verifier (#324)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from reviewer_fallback import assess_reviewer_fallback_report # noqa: E402
class TestReviewerFallbackNormalMode(unittest.TestCase):
def test_clean_mcp_report_passes(self):
report = (
"Used gitea_list_prs and gitea_view_pr via MCP reviewer namespace.\n"
"Capability evidence: gitea_resolve_task_capability(review_pr)."
)
result = assess_reviewer_fallback_report(report, mcp_available=True)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_profiles_json_access_blocks(self):
report = (
"Inspected ~/.config/gitea-tools/profiles.json for reviewer credentials.\n"
"Then used gitea_view_pr."
)
result = assess_reviewer_fallback_report(report, mcp_available=True)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(any("profiles.json" in r for r in result["reasons"]))
def test_local_list_prs_script_blocks_when_mcp_available(self):
report = (
"Ran `python list_prs.py --remote prgs` with GITEA_MCP_CONFIG set.\n"
"MCP tools were also available."
)
result = assess_reviewer_fallback_report(
report,
mcp_available=True,
action_log=[{"command": "GITEA_MCP_PROFILE=prgs-reviewer python list_prs.py"}],
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(result["script_violations"])
def test_action_log_profile_access_blocks(self):
result = assess_reviewer_fallback_report(
"Review complete via MCP.",
action_log=[{"path": "/Users/me/.config/gitea-tools/profiles.json", "action": "read"}],
mcp_available=True,
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
class TestReviewerFallbackRecoveryMode(unittest.TestCase):
def _recovery_report(self, **extra):
base = "\n".join([
"Recovery mode: MCP tools unavailable in this session.",
"Local fallback: ran python view_pr.py after capability proof.",
"Identity proof: sysadmin / prgs-reviewer",
"Profile proof: prgs-reviewer",
"Repo proof: Scaled-Tech-Consulting/Gitea-Tools",
"Capability proof: gitea.pr.review allowed for recovery command",
"Why MCP was unavailable: reviewer MCP namespace failed to start",
])
return base + ("\n" + extra.get("append", "") if extra.get("append") else "")
def test_recovery_fallback_with_proof_passes(self):
result = assess_reviewer_fallback_report(
self._recovery_report(),
action_log=[{"command": "python view_pr.py --remote prgs"}],
mcp_available=False,
recovery_mode=True,
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_recovery_fallback_without_proof_blocks(self):
report = (
"Recovery mode engaged.\n"
"Local fallback: python list_prs.py\n"
)
result = assess_reviewer_fallback_report(
report,
mcp_available=False,
recovery_mode=True,
action_log=[{"command": "python list_prs.py"}],
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(
any("recovery" in r.lower() for r in result["reasons"])
)
def test_mcp_unavailable_allows_recovery_attempt(self):
result = assess_reviewer_fallback_report(
self._recovery_report(),
mcp_available=False,
recovery_mode=True,
)
self.assertTrue(result["proven"])
class TestReviewerFallbackExport(unittest.TestCase):
def test_review_proofs_reexport(self):
from review_proofs import assess_reviewer_fallback_report as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
+150
View File
@@ -0,0 +1,150 @@
"""Tests for non-mergeable skip conflict-proof verifier (#322)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from reviewer_mergeability_skip import assess_non_mergeable_skip_proof # noqa: E402
HEAD_A = "0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
HEAD_B = "1111111111111111111111111111111111111111"
def _good_skip_report(pr_number: int, head: str, files: str = "review_proofs.py") -> str:
return "\n".join([
f"Skipped PR #{pr_number} (non-mergeable).",
f"- PR number: #{pr_number}",
f"- Current head SHA: {head}",
"- Mergeability result: false",
"- Conflict proof command: git merge-tree $(git merge-base prgs/master prgs/feat) prgs/master prgs/feat",
f"- Conflicting files: {files}",
"- Head unchanged since prior blocker",
"- Blocking category: merge conflict",
])
class TestNonMergeableSkipProof(unittest.TestCase):
def test_no_skips_passes(self):
report = "Selected PR #203 for review. No earlier PRs skipped."
result = assess_non_mergeable_skip_proof(report, skipped_prs=[])
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_documented_non_mergeable_skip_passes(self):
report = _good_skip_report(282, HEAD_A)
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 282,
"mergeable": False,
"head_sha": HEAD_A,
"mergeability_verified": True,
"conflicting_files": ["review_proofs.py"],
"head_changed_since_prior_blocker": False,
}],
)
self.assertTrue(result["proven"])
def test_skip_without_conflict_proof_blocks(self):
report = "\n".join([
"Skipped PR #284 because it was not mergeable.",
f"Head SHA: {HEAD_B}",
])
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 284,
"mergeable": False,
"head_sha": HEAD_B,
"mergeability_verified": True,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(any("conflict proof" in r for r in result["reasons"]))
def test_missing_head_sha_blocks(self):
report = _good_skip_report(282, HEAD_A).replace(HEAD_A, "")
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 282,
"mergeable": False,
"head_sha": HEAD_A,
"mergeability_verified": True,
}],
)
self.assertFalse(result["proven"])
def test_mergeability_unverified_classification_passes(self):
report = "\n".join([
"Skipped PR #282.",
"Classification: MERGEABILITY_UNVERIFIED",
"Conflict proof could not be retrieved in this session.",
])
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 282,
"mergeable": False,
"mergeability_verified": False,
}],
)
self.assertTrue(result["proven"])
def test_unverified_without_classification_blocks(self):
report = "Skipped PR #282 due to conflicts."
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 282,
"mergeable": False,
"mergeability_verified": False,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(any("MERGEABILITY_UNVERIFIED" in r for r in result["reasons"]))
def test_non_mergeable_without_file_details_still_passes_with_command(self):
report = _good_skip_report(284, HEAD_B, files="not available")
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 284,
"mergeable": False,
"head_sha": HEAD_B,
"mergeability_verified": True,
"conflicting_files": [],
}],
)
self.assertTrue(result["proven"])
def test_stale_head_requires_head_change_field(self):
report = _good_skip_report(282, HEAD_A).replace(
"- Head unchanged since prior blocker",
"",
)
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 282,
"mergeable": False,
"head_sha": HEAD_A,
"mergeability_verified": True,
"head_changed_since_prior_blocker": True,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(any("head-changed" in r for r in result["reasons"]))
class TestExport(unittest.TestCase):
def test_review_proofs_reexport(self):
from review_proofs import assess_non_mergeable_skip_proof as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
+183
View File
@@ -0,0 +1,183 @@
"""Tests for reviewer worktree safety proofs (Issue #233)."""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from reviewer_worktree import ( # noqa: E402
assess_author_worktree_continuity,
assess_reviewer_git_command_log,
assess_reviewer_worktree_proof,
files_outside_pr_scope,
is_forbidden_reviewer_git_command,
is_readonly_reviewer_git_command,
parse_dirty_tracked_files,
)
class TestParseDirtyTrackedFiles(unittest.TestCase):
def test_ignores_untracked_files(self):
porcelain = "?? untracked.txt\n M tracked.py\n"
self.assertEqual(parse_dirty_tracked_files(porcelain), ["tracked.py"])
def test_parses_renamed_paths(self):
porcelain = "R old.py -> new.py\n"
self.assertEqual(parse_dirty_tracked_files(porcelain), ["new.py"])
class TestFilesOutsidePrScope(unittest.TestCase):
def test_all_dirty_in_scope_is_clean(self):
self.assertEqual(
files_outside_pr_scope(
["docs/wiki/Repositories.md"],
["docs/wiki/Repositories.md"],
),
[],
)
def test_unrelated_dirty_files_detected(self):
self.assertEqual(
files_outside_pr_scope(
["review_proofs.py", "docs/wiki/Repositories.md"],
["docs/wiki/Repositories.md"],
),
["review_proofs.py"],
)
class TestForbiddenGitCommands(unittest.TestCase):
def test_blocks_stash_operations(self):
self.assertTrue(is_forbidden_reviewer_git_command("git stash"))
self.assertTrue(
is_forbidden_reviewer_git_command(
'git stash push -m "reviewer-temp-stash" -- tests/test_mcp_server.py'
)
)
self.assertTrue(is_forbidden_reviewer_git_command("git stash pop"))
self.assertTrue(is_forbidden_reviewer_git_command("git stash drop"))
def test_blocks_checkout_reset_and_clean(self):
self.assertTrue(
is_forbidden_reviewer_git_command(
"git checkout -- review_proofs.py tests/test_mcp_server.py"
)
)
self.assertTrue(is_forbidden_reviewer_git_command("git reset --hard"))
self.assertTrue(is_forbidden_reviewer_git_command("git clean -fd"))
def test_blocks_checkout_restore_and_switch_bypasses(self):
"""Issue #243: blocklist gaps closed via readonly allowlist model."""
blocked = (
"git checkout HEAD -- review_proofs.py",
"git checkout prgs/master -- review_proofs.py",
"git checkout .",
"git switch -",
"git switch -C feat/other-branch",
"git switch master",
"git stash store",
"git stash branch wip-stash",
)
for cmd in blocked:
with self.subTest(cmd=cmd):
self.assertTrue(is_forbidden_reviewer_git_command(cmd))
self.assertFalse(is_readonly_reviewer_git_command(cmd))
def test_allows_readonly_commands(self):
for cmd in (
"git fetch prgs master",
"git status --porcelain",
"git diff prgs/master...HEAD",
"git rev-parse HEAD",
"git -C /repo log -1",
):
with self.subTest(cmd=cmd):
self.assertFalse(is_forbidden_reviewer_git_command(cmd))
self.assertTrue(is_readonly_reviewer_git_command(cmd))
class TestAssessReviewerWorktreeProof(unittest.TestCase):
def test_clean_worktree_proceeds(self):
result = assess_reviewer_worktree_proof({
"worktree_path": "/repo/branches/review-pr-231",
"porcelain_status": "",
"pr_scope_files": ["docs/wiki/Repositories.md"],
"scratch_used": False,
"git_commands": ["git fetch prgs master", "git diff prgs/master...HEAD"],
})
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_dirty_unrelated_without_scratch_blocks(self):
result = assess_reviewer_worktree_proof({
"worktree_path": "/repo",
"dirty_files": ["review_proofs.py", "tests/test_review_proofs.py"],
"pr_scope_files": ["docs/wiki/Repositories.md"],
"scratch_used": False,
})
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertIn("review_proofs.py", result["unrelated_dirty_files"][0])
def test_scratch_worktree_allows_dirty_main_repo(self):
result = assess_reviewer_worktree_proof({
"worktree_path": "/repo",
"dirty_files": ["review_proofs.py"],
"pr_scope_files": ["docs/wiki/Repositories.md"],
"scratch_used": True,
"scratch_path": "/repo/branches/review-feat-issue-224",
})
self.assertTrue(result["proven"])
def test_forbidden_command_history_blocks(self):
result = assess_reviewer_worktree_proof({
"worktree_path": "/repo/branches/review-pr-231",
"porcelain_status": "",
"git_commands": ["git stash push -m temp -- review_proofs.py"],
})
self.assertFalse(result["proven"])
self.assertTrue(result["forbidden_commands"])
def test_unrelated_mutations_claimed_blocks(self):
result = assess_reviewer_worktree_proof({
"worktree_path": "/repo",
"porcelain_status": "",
"unrelated_mutations_claimed": True,
})
self.assertFalse(result["proven"])
class TestAuthorContinuity(unittest.TestCase):
def test_author_may_keep_dirty_worktree(self):
result = assess_author_worktree_continuity({
"task_role": "author",
"dirty_files": ["feat.py"],
})
self.assertTrue(result["allowed"])
def test_reviewer_dirty_worktree_uses_reviewer_gate(self):
result = assess_author_worktree_continuity({
"task_role": "reviewer",
"worktree_path": "/repo",
"dirty_files": ["other.py"],
"pr_scope_files": ["docs/a.md"],
"scratch_used": False,
})
self.assertFalse(result["proven"])
class TestAssessReviewerGitCommandLog(unittest.TestCase):
def test_empty_log_is_clean(self):
result = assess_reviewer_git_command_log([])
self.assertTrue(result["proven"])
def test_mixed_log_blocks_on_forbidden(self):
result = assess_reviewer_git_command_log([
"git fetch prgs",
"git stash",
])
self.assertFalse(result["proven"])
self.assertEqual(len(result["forbidden_commands"]), 1)
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -119,6 +119,25 @@ class TestRoleSessionRouter(unittest.TestCase):
]
self.assertEqual(issue_posts, [])
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request", return_value={"number": 888})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_author_task_allowed_after_capability_resolve(
self, _auth, _api, _get_all
):
"""#228: explicit create_issue capability clears reviewer wrong_role_stop."""
with patch.dict(os.environ, self._env("prgs-author")):
mcp_server.gitea_route_task_session(task_type="review_pr", remote="prgs")
cap = mcp_server.gitea_resolve_task_capability(
task="create_issue", remote="prgs"
)
self.assertTrue(cap["allowed_in_current_session"])
result = mcp_server.gitea_create_issue(
title="operation-scoped author recovery",
remote="prgs",
)
self.assertEqual(result.get("number"), 888)
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request", return_value={"number": 999})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
@@ -185,5 +204,56 @@ class TestRoleSessionRouter(unittest.TestCase):
self.assertTrue(complete["complete"])
class TestCheckMidMerge(unittest.TestCase):
def test_skip_scan_walk_root_skips_sibling_worktrees_only(self):
# Hermetic <main>/branches/<worktree> layout (#283): deriving these
# paths from __file__ made the test pass only when the suite ran from
# a branches/ worktree, because skip_python_scan_walk_root skips a
# branches/ walk root only when <project_root>/branches exists.
with tempfile.TemporaryDirectory() as tmp:
main_root = os.path.join(tmp, "main")
worktree_root = os.path.join(main_root, "branches", "fix-issue-1")
os.makedirs(os.path.join(worktree_root, "tests"))
self.assertFalse(
role_session_router.skip_python_scan_walk_root(
main_root, main_root
)
)
self.assertTrue(
role_session_router.skip_python_scan_walk_root(
main_root, os.path.join(main_root, "branches", "fix-issue-1")
)
)
self.assertFalse(
role_session_router.skip_python_scan_walk_root(
worktree_root, worktree_root
)
)
self.assertFalse(
role_session_router.skip_python_scan_walk_root(
worktree_root, os.path.join(worktree_root, "tests")
)
)
def test_decorative_equals_banner_is_not_mid_merge(self):
self.assertFalse(role_session_router.check_mid_merge())
def test_python_bytes_have_conflict_markers_rejects_decorative_equals(self):
banner = b"===========================================\n"
self.assertFalse(role_session_router.python_bytes_have_conflict_markers(banner))
def test_python_bytes_have_conflict_markers_detects_real_markers(self):
self.assertTrue(
role_session_router.python_bytes_have_conflict_markers(b"<<<<<<< HEAD\n")
)
self.assertTrue(
role_session_router.python_bytes_have_conflict_markers(b"=======\n")
)
self.assertTrue(
role_session_router.python_bytes_have_conflict_markers(b">>>>>>> topic\n")
)
if __name__ == "__main__":
unittest.main()
+31 -2
View File
@@ -95,9 +95,13 @@ class TestRuntimeClarity(unittest.TestCase):
# -------------------------------------------------------------------------
# gitea_get_runtime_context
# -------------------------------------------------------------------------
@patch(
"mcp_server.assess_preflight_status",
return_value={"preflight_ready": True, "preflight_block_reasons": []},
)
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_get_runtime_context_author(self, _auth, _api):
def test_get_runtime_context_author(self, _auth, _api, _preflight):
with patch.dict(os.environ, self._env("author-profile"), clear=True):
ctx = mcp_server.gitea_get_runtime_context(remote="dadeschools")
self.assertEqual(ctx["active_profile"], "author-profile")
@@ -110,10 +114,26 @@ class TestRuntimeClarity(unittest.TestCase):
self.assertEqual(ctx["suggested_fix"], "reviewer namespace")
self.assertIn("does not permit review or merge", ctx["review_merge_blocked_reasons"][0])
self.assertIn("Switch to the reviewer MCP session", ctx["safe_next_action"])
caps = ctx["session_capabilities"]
self.assertTrue(caps["can_author_prs"])
self.assertFalse(caps["can_create_issues"])
self.assertFalse(caps["can_comment_on_issues"])
self.assertFalse(caps["can_review_prs"])
self.assertFalse(caps["can_merge_prs"])
self.assertTrue(caps["issue_comment_not_implied_by_pr_comment"])
merge_entry = next(
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
)
self.assertFalse(merge_entry["allowed_in_current_session"])
self.assertIn("reviewer-profile", merge_entry["matching_configured_profiles"])
@patch(
"mcp_server.assess_preflight_status",
return_value={"preflight_ready": True, "preflight_block_reasons": []},
)
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_get_runtime_context_reviewer(self, _auth, _api):
def test_get_runtime_context_reviewer(self, _auth, _api, _preflight):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
ctx = mcp_server.gitea_get_runtime_context(remote="dadeschools")
self.assertEqual(ctx["active_profile"], "reviewer-profile")
@@ -121,6 +141,15 @@ class TestRuntimeClarity(unittest.TestCase):
self.assertTrue(ctx["review_merge_allowed"])
self.assertEqual(ctx["suggested_fix"], "none")
self.assertEqual(ctx["safe_next_action"], "None; ready for operations.")
caps = ctx["session_capabilities"]
self.assertFalse(caps["can_author_prs"])
self.assertFalse(caps["can_create_issues"])
self.assertFalse(caps["can_review_prs"])
self.assertTrue(caps["can_merge_prs"])
merge_entry = next(
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
)
self.assertTrue(merge_entry["allowed_in_current_session"])
# -------------------------------------------------------------------------
# gitea_list_profiles
+82
View File
@@ -0,0 +1,82 @@
"""Doc-contract checks for the shell-spawn hard-stop rule (#258).
When the shell executor fails to spawn (exit_code: -1 with empty
stdout/stderr), agents must probe once, mark shell unavailable, hard-stop
after two consecutive spawn failures, and emit a recovery report instead of
retry-spiralling. These tests pin that guidance in the runbooks, the portable
skill doc, and the operator guide so it cannot silently regress.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md"
SKILL = REPO_ROOT / "skills" / "llm-project-workflow" / "SKILL.md"
def _normalize(text: str) -> str:
"""Collapse whitespace so phrase checks survive markdown line wrapping."""
return " ".join(text.split())
def _runbook_text() -> str:
return _normalize(RUNBOOK.read_text(encoding="utf-8"))
def _skill_text() -> str:
return _normalize(SKILL.read_text(encoding="utf-8"))
def test_runbook_has_shell_spawn_hard_stop_section():
text = _runbook_text()
assert "## Shell Spawn Hard-Stop Rule" in text
for phrase in (
"exit_code: -1",
"empty stdout/stderr",
"trivial probe",
"two consecutive spawn failures",
"mark shell unavailable",
"recovery report",
):
assert phrase in text, f"runbook missing phrase: {phrase!r}"
def test_runbook_recovery_report_names_required_steps():
text = _runbook_text()
for phrase in (
"restart the session",
"kill hung background terminals",
"MCP-native",
):
assert phrase in text, f"runbook recovery guidance missing: {phrase!r}"
def test_runbook_forbids_retry_spirals():
text = _runbook_text()
assert "never retry the same failing spawn" in text
def test_skill_doc_declares_shell_spawn_hard_stop_rule():
text = _skill_text()
assert "## Shell Spawn Hard-Stop Rule" in text
for phrase in (
"exit_code: -1",
"two consecutive spawn failures",
"recovery report",
):
assert phrase in text, f"SKILL.md missing phrase: {phrase!r}"
def test_operator_guide_rules_include_shell_spawn_hard_stop():
import sys
sys.path.insert(0, str(REPO_ROOT))
import gitea_mcp_server
rule = gitea_mcp_server._GUIDE_RULES["shell_spawn_hard_stop"]
for phrase in (
"exit_code: -1",
"two consecutive",
"recovery report",
):
assert phrase in rule, f"operator guide rule missing: {phrase!r}"
+152
View File
@@ -0,0 +1,152 @@
"""Tests for fail-closed subagent delegation gates (#266).
Covers the acceptance criteria: blocked write delegation, allowed read-only
delegation, missing inherited context, and invalid subagent final reports.
"""
import unittest
import subagent_gate
def _full_context():
return {
"issue_lock": "issue #266 locked to feat/issue-266-subagent-gate-inheritance",
"branch": "feat/issue-266-subagent-gate-inheritance",
"worktree_path": "branches/feat-issue-266-subagent-gate-inheritance",
"identity_profile": "jcwalker3/prgs-author",
"allowed_tool_class": "read_write_files",
"command_deny_list": "git push --force; rm -rf",
"validation_ledger_requirement": "record command/exit/output for every validation claim",
"final_report_schema": "controller-handoff-v1",
}
def _full_report():
return {
"identity_profile": "jcwalker3/prgs-author",
"worktree_path": "branches/feat-issue-266-subagent-gate-inheritance",
"branch": "feat/issue-266-subagent-gate-inheritance",
"changed_files": "subagent_gate.py, tests/test_subagent_gate.py",
"validation_results": "pytest tests/test_subagent_gate.py -q: exit 0, 14 passed",
"workspace_mutations": "edited subagent_gate.py",
}
class TestAssessSubagentDelegation(unittest.TestCase):
# AC1: deterministic write tasks are blocked by default
def test_write_delegation_blocked_by_default(self):
for task in ("claim_issue", "create_branch", "edit_code", "commit",
"push_branch", "create_pr", "review_pr", "merge_pr",
"cleanup_branch"):
res = subagent_gate.assess_subagent_delegation(task)
self.assertTrue(res["block"], task)
self.assertFalse(res["allowed"], task)
self.assertEqual(res["task_class"], "deterministic_write", task)
self.assertTrue(
any("explicitly allowed" in r for r in res["reasons"]), task)
# AC2: explicit allowance without a recorded justification still blocks
def test_write_delegation_requires_recorded_justification(self):
res = subagent_gate.assess_subagent_delegation(
"create_pr", explicitly_allowed=True,
inherited_context=_full_context())
self.assertTrue(res["block"])
self.assertTrue(
any("justification" in r for r in res["reasons"]))
# AC3: explicit allowance + justification but missing inherited context
def test_write_delegation_blocks_on_missing_inherited_context(self):
context = _full_context()
del context["issue_lock"]
del context["command_deny_list"]
res = subagent_gate.assess_subagent_delegation(
"commit", explicitly_allowed=True,
justification="parent session proved batch commit needs isolation",
inherited_context=context)
self.assertTrue(res["block"])
self.assertIn("issue_lock", res["missing_context"])
self.assertIn("command_deny_list", res["missing_context"])
def test_write_delegation_blocks_on_empty_context_values(self):
context = _full_context()
context["worktree_path"] = " "
res = subagent_gate.assess_subagent_delegation(
"commit", explicitly_allowed=True,
justification="isolation required",
inherited_context=context)
self.assertTrue(res["block"])
self.assertIn("worktree_path", res["missing_context"])
def test_write_delegation_allowed_with_authorization_and_full_context(self):
res = subagent_gate.assess_subagent_delegation(
"edit_code", explicitly_allowed=True,
justification="parallel mechanical rename across many files",
inherited_context=_full_context())
self.assertFalse(res["block"])
self.assertTrue(res["allowed"])
self.assertEqual(res["missing_context"], [])
# Allowed read-only delegation needs no explicit authorization
def test_read_only_delegation_allowed(self):
for task in ("read_files", "code_search", "inventory_prs",
"summarize_issue"):
res = subagent_gate.assess_subagent_delegation(task)
self.assertFalse(res["block"], task)
self.assertTrue(res["allowed"], task)
self.assertEqual(res["task_class"], "read_only", task)
# Unknown task types fail closed
def test_unknown_task_fails_closed(self):
res = subagent_gate.assess_subagent_delegation("launch_missiles")
self.assertTrue(res["block"])
self.assertEqual(res["task_class"], "unknown")
def test_blank_task_fails_closed(self):
res = subagent_gate.assess_subagent_delegation(" ")
self.assertTrue(res["block"])
self.assertEqual(res["task_class"], "unknown")
class TestValidateSubagentReport(unittest.TestCase):
# AC4: subagent output must carry the same proof fields as the parent
def test_full_report_valid(self):
res = subagent_gate.validate_subagent_report(_full_report())
self.assertTrue(res["valid"])
self.assertFalse(res["block"])
self.assertEqual(res["missing_fields"], [])
def test_missing_proof_fields_invalid(self):
report = _full_report()
del report["validation_results"]
del report["worktree_path"]
res = subagent_gate.validate_subagent_report(report)
self.assertFalse(res["valid"])
self.assertTrue(res["block"])
self.assertIn("validation_results", res["missing_fields"])
self.assertIn("worktree_path", res["missing_fields"])
def test_empty_field_values_invalid(self):
report = _full_report()
report["changed_files"] = ""
res = subagent_gate.validate_subagent_report(report)
self.assertFalse(res["valid"])
self.assertIn("changed_files", res["missing_fields"])
def test_none_report_invalid(self):
res = subagent_gate.validate_subagent_report(None)
self.assertFalse(res["valid"])
self.assertTrue(res["block"])
class TestOperatorGuideRule(unittest.TestCase):
def test_operator_guide_declares_subagent_rule(self):
import gitea_mcp_server
rule = gitea_mcp_server._GUIDE_RULES["subagent_delegation"].lower()
for phrase in ("deterministic write", "inherit", "read-only",
"fail closed"):
self.assertIn(phrase, rule)
if __name__ == "__main__":
unittest.main()
+89
View File
@@ -0,0 +1,89 @@
"""Doc-contract checks for subagent tool-budget guardrails (#259).
Single-step MCP tasks must not expand into 100+ tool-call retry spirals with
WebFetch/Playwright/manual-encoding fallbacks. These tests pin budget defaults,
native-MCP-first rules, and forbidden detours in the runbooks, portable skill,
and operator guide.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md"
SKILL = REPO_ROOT / "skills" / "llm-project-workflow" / "SKILL.md"
def _normalize(text: str) -> str:
"""Collapse whitespace so phrase checks survive markdown line wrapping."""
return " ".join(text.split())
def _runbook_text() -> str:
return _normalize(RUNBOOK.read_text(encoding="utf-8"))
def _skill_text() -> str:
return _normalize(SKILL.read_text(encoding="utf-8"))
def test_runbook_has_subagent_tool_budget_section():
text = _runbook_text()
assert "## Subagent Tool-Budget Guardrails" in text
for phrase in (
"15",
"5 minutes",
"40",
"15 minutes",
"gitea_commit_files",
"WebFetch",
"Playwright",
"no retry spirals",
):
assert phrase.lower() in text.lower(), f"runbook missing phrase: {phrase!r}"
def test_runbook_forbids_subagent_commit_delegation():
text = _runbook_text()
for phrase in (
"do not delegate commit authority to a subagent",
"main session first",
"native MCP before fallback",
):
assert phrase.lower() in text.lower(), f"runbook missing: {phrase!r}"
def test_runbook_rejects_fallback_detours_when_mcp_commit_available():
lower = _runbook_text().lower()
for forbidden in ("webfetch", "playwright", "manual llm-generated base64"):
assert forbidden in lower, f"runbook must name forbidden detour: {forbidden!r}"
assert "_encode_" in lower
assert "gitea_commit_files" in lower
def test_skill_doc_declares_subagent_tool_budget_guardrails():
text = _skill_text()
assert "## Subagent Tool-Budget Guardrails" in text
for phrase in (
"15 tool calls",
"5 minutes",
"gitea_commit_files",
"WebFetch",
"never resume a failed subagent",
):
assert phrase.lower() in text.lower(), f"SKILL.md missing phrase: {phrase!r}"
def test_operator_guide_rules_include_subagent_tool_budget():
import sys
sys.path.insert(0, str(REPO_ROOT))
import gitea_mcp_server
rule = gitea_mcp_server._GUIDE_RULES["subagent_tool_budget"]
for phrase in (
"15 tool calls",
"gitea_commit_files",
"WebFetch",
"retry loop",
):
assert phrase in rule, f"operator guide rule missing: {phrase!r}"
+187
View File
@@ -0,0 +1,187 @@
"""Tests for the session hard-stop after a terminal review mutation (#332).
Extends the single-terminal-decision machinery (#211): after a live
REQUEST_CHANGES the run must stop; after an APPROVE only the merge sequence
for that same PR may continue; a different PR can never be reviewed or
merged in the same run; duplicate REQUEST_CHANGES at an unchanged head is
suppressed.
"""
import os
import unittest
from unittest.mock import patch
import mcp_server
def _lock(mutations=None, correction=False):
return {
"task": "review_pr",
"remote": "prgs",
"session_pid": os.getpid(),
"session_profile": "prgs-reviewer",
"session_profile_lock": "",
"final_review_decision_ready": False,
"ready_pr_number": None,
"ready_action": None,
"ready_expected_head_sha": None,
"ready_remote": None,
"ready_org": None,
"ready_repo": None,
"live_mutations": list(mutations or []),
"correction_authorized": correction,
"correction_reason": None,
}
def _seed(mutations=None, correction=False):
mcp_server._save_review_decision_lock(_lock(mutations, correction))
APPROVED_A = {"pr_number": 5, "action": "approve", "review_id": 1,
"review_state": "approve"}
RC_A = {"pr_number": 5, "action": "request_changes", "review_id": 2,
"review_state": "request_changes"}
class TestTerminalHardStopReasons(unittest.TestCase):
def tearDown(self):
mcp_server._save_review_decision_lock(None)
def test_no_lock_no_reasons(self):
mcp_server._save_review_decision_lock(None)
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
def test_no_terminal_mutation_no_reasons(self):
_seed()
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
def test_request_changes_blocks_everything(self):
_seed([RC_A])
for op in ("merge", "mark_ready", "review"):
for pr in (5, 6):
reasons = mcp_server.terminal_review_hard_stop_reasons(pr, op)
self.assertTrue(reasons, f"{op}/{pr}")
self.assertIn("request_changes on PR #5", reasons[0])
self.assertIn("#332", reasons[0])
def test_approve_allows_same_pr_merge_only(self):
_seed([APPROVED_A])
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
self.assertTrue(
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
self.assertTrue(
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"))
self.assertTrue(
mcp_server.terminal_review_hard_stop_reasons(6, "review"))
def test_correction_reopens_review_path_only(self):
_seed([RC_A], correction=True)
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(5, "mark_ready"), [])
self.assertEqual(
mcp_server.terminal_review_hard_stop_reasons(5, "review"), [])
# correction never opens a cross-PR merge
self.assertTrue(
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
class TestMergeHardStopWiring(unittest.TestCase):
def tearDown(self):
mcp_server._save_review_decision_lock(None)
def test_merge_blocked_after_request_changes(self):
_seed([RC_A])
result = mcp_server.gitea_merge_pr(
pr_number=6, confirmation="MERGE PR 6", remote="prgs")
self.assertFalse(result["performed"])
self.assertTrue(
any("#332" in r for r in result["reasons"]), result["reasons"])
def test_merge_of_other_pr_blocked_after_approve(self):
_seed([APPROVED_A])
result = mcp_server.gitea_merge_pr(
pr_number=6, confirmation="MERGE PR 6", remote="prgs")
self.assertFalse(result["performed"])
self.assertTrue(
any("#332" in r for r in result["reasons"]), result["reasons"])
class TestMarkFinalHardStopWiring(unittest.TestCase):
def tearDown(self):
mcp_server._save_review_decision_lock(None)
def test_mark_ready_blocked_after_terminal_mutation(self):
_seed([RC_A])
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="approve", remote="prgs")
self.assertFalse(result["marked_ready"])
self.assertTrue(
any("#332" in r for r in result["reasons"]), result["reasons"])
def _feedback(blocking, stale=False, success=True):
return {
"success": success,
"has_blocking_change_requests": blocking,
"review_feedback_stale": stale,
"current_head_sha": "abc123",
}
class TestDuplicateRequestChangesSuppression(unittest.TestCase):
def tearDown(self):
mcp_server._save_review_decision_lock(None)
def test_duplicate_request_changes_blocked_at_same_head(self):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=True, stale=False)):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="request_changes", remote="prgs")
self.assertFalse(result["marked_ready"])
self.assertTrue(
any("duplicate" in r for r in result["reasons"]),
result["reasons"])
def test_request_changes_allowed_when_prior_blocker_stale(self):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=True, stale=True)):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="request_changes", remote="prgs")
self.assertTrue(result["marked_ready"], result.get("reasons"))
def test_request_changes_allowed_when_no_blocker(self):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=False)):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="request_changes", remote="prgs")
self.assertTrue(result["marked_ready"], result.get("reasons"))
def test_request_changes_fails_closed_when_feedback_unavailable(self):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=False,
success=False)):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="request_changes", remote="prgs")
self.assertFalse(result["marked_ready"])
self.assertTrue(
any("could not verify" in r for r in result["reasons"]),
result["reasons"])
def test_approve_does_not_fetch_feedback(self):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
side_effect=AssertionError("must not be called")):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="approve", remote="prgs")
self.assertTrue(result["marked_ready"], result.get("reasons"))
if __name__ == "__main__":
unittest.main()
+155
View File
@@ -0,0 +1,155 @@
"""Tests for validation ledger and final-report verifier (#271)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from validation_ledger import ( # noqa: E402
assess_full_suite_claim,
assess_ledger_entry,
new_ledger_entry,
verify_final_report,
)
class TestLedgerEntry(unittest.TestCase):
def test_valid_entry_is_claimable(self):
entry = new_ledger_entry(
command="pytest tests/ -q",
cwd="/repo/branches/feat-issue-271",
exit_code=0,
output_summary="994 passed, 6 skipped",
)
result = assess_ledger_entry(entry)
self.assertTrue(result["claimable"])
self.assertEqual(result["verdict"], "strong")
def test_missing_command_output_is_invalid(self):
entry = new_ledger_entry(
command="pytest tests/ -q",
cwd="/repo/branches/task",
exit_code=0,
output_summary="",
)
result = assess_ledger_entry(entry)
self.assertFalse(result["claimable"])
self.assertEqual(result["verdict"], "invalid")
class TestFullSuiteClaim(unittest.TestCase):
def _entry(self, **kwargs):
base = new_ledger_entry(
command="pytest tests/ -q",
cwd="/repo/branches/task",
exit_code=0,
output_summary="10 passed",
)
base.update(kwargs)
return base
def test_full_suite_overclaim_with_ignored_paths_fails(self):
entry = self._entry(
ignored_paths=[{"path": "tests/integration/", "justification": ""}],
)
result = assess_full_suite_claim(
[entry],
report_text="Validation: full suite passed.",
)
self.assertFalse(result["claimable"])
self.assertTrue(
any("full suite" in r.lower() for r in result["reasons"])
)
def test_full_suite_except_note_allows_ignored_paths(self):
entry = self._entry(
ignored_paths=[{
"path": "tests/integration/",
"justification": "network tests require GITEA_INTEGRATION=1",
}],
)
result = assess_full_suite_claim(
[entry],
report_text="Validation: full suite except integration tests.",
)
self.assertTrue(result["claimable"])
class TestFinalReportVerifier(unittest.TestCase):
def _base_report(self, **overrides):
report = {
"identity": "jcwalker3",
"profile": "prgs-author",
"capability_resolved": True,
"worktree_path": "/repo/branches/feat-issue-271",
"changed_files": ["validation_ledger.py"],
"validation_ledger": [
new_ledger_entry(
command="pytest tests/test_validation_ledger.py -q",
cwd="/repo/branches/feat-issue-271",
exit_code=0,
output_summary="12 passed",
)
],
"validation_claim": "passed",
"review_submitted": False,
"review_visible_approved": False,
"review_pending": False,
"merge_performed": False,
"merge_blocker": "not a reviewer task",
"workspace_clean": True,
}
report.update(overrides)
return report
def test_complete_report_is_proven(self):
result = verify_final_report(self._base_report())
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_validation_overclaim_fails(self):
report = self._base_report(
validation_claim="passed",
validation_ledger=[
new_ledger_entry(
command="pytest tests/ -q",
cwd="/repo/branches/task",
exit_code=1,
output_summary="3 failed",
)
],
)
result = verify_final_report(report)
self.assertFalse(result["proven"])
self.assertTrue(result["contradictions"])
def test_pending_approval_misreported_as_approved_fails(self):
report = self._base_report(
claims_approved_review=True,
review_visible_approved=False,
review_pending=True,
)
result = verify_final_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("approved" in c.lower() for c in result["contradictions"])
)
def test_missing_command_output_in_ledger_fails(self):
report = self._base_report(
validation_ledger=[
new_ledger_entry(
command="pytest tests/ -q",
cwd="/repo/branches/task",
exit_code=0,
output_summary="",
)
],
)
result = verify_final_report(report)
self.assertFalse(result["proven"])
self.assertTrue(result["reasons"])
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
"""Documentation checks for MCP-native commit path rules (#260)."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md"
SAFETY = REPO_ROOT / "docs" / "safety-model.md"
def test_runbook_requires_mcp_native_commit_path():
text = RUNBOOK.read_text(encoding="utf-8")
assert "MCP-native commit path" in text
assert "gitea_commit_files" in text
assert "gitea.repo.commit" in text
assert "only" in text.lower() and "approved" in text.lower()
lower = text.lower()
for forbidden in ("webfetch", "playwright", "manual llm-generated base64"):
assert forbidden in lower
def test_runbook_forbids_fallback_loops():
lower = RUNBOOK.read_text(encoding="utf-8").lower()
assert "retry shell encoding" in lower
assert "loop" in lower
assert "recovery report" in lower
def test_safety_model_documents_commit_fallback_ban():
text = SAFETY.read_text(encoding="utf-8")
assert "Agent Commit Path" in text
assert "gitea_commit_files" in text
assert "WebFetch" in text
assert "Playwright" in text
assert "llm-workflow-runbooks.md" in text
@@ -0,0 +1,165 @@
"""Tests for workspace-vs-worktree mutation consistency verifier (#313).
Final reports must not claim ``Workspace mutations: none`` when worktree
mutations (reset --hard, checkout, clean, worktree add/remove, ...)
occurred, and must report observed worktree / git ref mutations under the
precise category fields.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_workspace_mutation_consistency # noqa: E402
class TestWorkspaceNoneContradiction(unittest.TestCase):
def test_reset_hard_with_workspace_none_fails(self):
report = (
"Controller Handoff\n"
"- Workspace mutations: none (no local file edits)\n"
)
result = assess_workspace_mutation_consistency(
report,
observed_commands=["git reset --hard FETCH_HEAD"],
)
self.assertFalse(result["complete"])
self.assertTrue(result["downgraded"])
self.assertTrue(
any("workspace mutations" in r.lower() for r in result["reasons"])
)
def test_checkout_with_workspace_none_fails(self):
report = "- Workspace mutations: none\n"
result = assess_workspace_mutation_consistency(
report,
observed_commands=["git checkout feat/some-branch"],
)
self.assertFalse(result["complete"])
def test_worktree_add_with_workspace_none_fails(self):
report = "- Workspace mutations: none\n"
result = assess_workspace_mutation_consistency(
report,
observed_commands=["git worktree add /tmp/review-pr1 abc123"],
)
self.assertFalse(result["complete"])
def test_clean_with_workspace_none_fails(self):
report = "- Workspace mutations: none\n"
result = assess_workspace_mutation_consistency(
report,
observed_commands=["git clean -fd"],
)
self.assertFalse(result["complete"])
def test_self_reported_worktree_mutation_contradicts_workspace_none(self):
# No observed command log; the report itself carries the
# contradiction (#313 observed behavior).
report = (
"- Worktree mutations: git reset --hard FETCH_HEAD\n"
"- Workspace mutations: none (no local file edits)\n"
)
result = assess_workspace_mutation_consistency(report)
self.assertFalse(result["complete"])
self.assertTrue(
any("workspace mutations" in r.lower() for r in result["reasons"])
)
class TestPreciseCategoriesPass(unittest.TestCase):
def test_file_edits_none_with_worktree_mutation_listed_passes(self):
report = (
"- File edits by reviewer: none\n"
"- Worktree mutations: git reset --hard FETCH_HEAD\n"
"- Git ref mutations: git fetch prgs\n"
)
result = assess_workspace_mutation_consistency(
report,
observed_commands=[
"git fetch prgs",
"git reset --hard FETCH_HEAD",
],
)
self.assertTrue(result["complete"])
self.assertFalse(result["downgraded"])
self.assertEqual(result["reasons"], [])
def test_noop_report_passes(self):
report = (
"- File edits by reviewer: none\n"
"- Worktree mutations: none\n"
"- Git ref mutations: none\n"
)
result = assess_workspace_mutation_consistency(
report, observed_commands=[]
)
self.assertTrue(result["complete"])
self.assertEqual(result["reasons"], [])
def test_fetch_only_with_ref_mutation_reported_passes(self):
# Fetch is a git ref mutation, not a worktree mutation; a
# workspace-none claim is not contradicted by fetch alone.
report = (
"- Workspace mutations: none\n"
"- Git ref mutations: git fetch prgs master\n"
)
result = assess_workspace_mutation_consistency(
report,
observed_commands=["git fetch prgs master"],
)
self.assertTrue(result["complete"])
class TestObservedMutationsMustBeReported(unittest.TestCase):
def test_reset_without_worktree_mutation_field_fails(self):
report = "- File edits by reviewer: none\n"
result = assess_workspace_mutation_consistency(
report,
observed_commands=["git reset --hard FETCH_HEAD"],
)
self.assertFalse(result["complete"])
self.assertTrue(
any("worktree mutation" in r.lower() for r in result["reasons"])
)
def test_reset_with_worktree_mutations_none_fails(self):
report = (
"- File edits by reviewer: none\n"
"- Worktree mutations: none\n"
)
result = assess_workspace_mutation_consistency(
report,
observed_commands=["git reset --hard FETCH_HEAD"],
)
self.assertFalse(result["complete"])
def test_fetch_without_ref_mutation_field_fails(self):
report = (
"- File edits by reviewer: none\n"
"- Worktree mutations: none\n"
)
result = assess_workspace_mutation_consistency(
report,
observed_commands=["git fetch prgs master"],
)
self.assertFalse(result["complete"])
self.assertTrue(
any("git ref mutation" in r.lower() for r in result["reasons"])
)
def test_fetch_with_ref_mutations_none_fails(self):
report = (
"- Worktree mutations: none\n"
"- Git ref mutations: none\n"
)
result = assess_workspace_mutation_consistency(
report,
observed_commands=["git fetch prgs master"],
)
self.assertFalse(result["complete"])
if __name__ == "__main__":
unittest.main()
+239
View File
@@ -0,0 +1,239 @@
"""Validation command ledger and final-report verifier (#271).
Sessions may only claim validation passed when the exact command, exit code,
and output summary are recorded. Final reports are checked against the
ledger and workflow proof fields before submission.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
LEDGER_REQUIRED_FIELDS = (
"command",
"cwd",
"exit_code",
"output_summary",
"timestamp",
)
OPTIONAL_LEDGER_FIELDS = (
"skipped_tests",
"ignored_paths",
)
def _clean(value: Any) -> str:
return (value or "").strip() if isinstance(value, str) else str(value or "").strip()
def normalize_ledger_entry(entry: dict | None) -> dict:
"""Return a normalized ledger entry with required keys present."""
data = dict(entry or {})
normalized = {
"command": _clean(data.get("command")),
"cwd": _clean(data.get("cwd")),
"exit_code": data.get("exit_code"),
"output_summary": _clean(data.get("output_summary")),
"timestamp": _clean(data.get("timestamp")),
"skipped_tests": data.get("skipped_tests") or [],
"ignored_paths": data.get("ignored_paths") or [],
}
return normalized
def assess_ledger_entry(entry: dict | None) -> dict:
"""Fail closed when a ledger entry cannot support a validation claim."""
normalized = normalize_ledger_entry(entry)
reasons: list[str] = []
for field in LEDGER_REQUIRED_FIELDS:
value = normalized.get(field)
if field == "exit_code":
if value is None or not isinstance(value, int):
reasons.append("exit_code missing or not an integer")
continue
if not _clean(value):
reasons.append(f"ledger field '{field}' missing or empty")
exit_code = normalized.get("exit_code")
if isinstance(exit_code, int) and exit_code != 0:
reasons.append(f"exit_code {exit_code} indicates failure")
if not _clean(normalized.get("output_summary")):
reasons.append("output_summary missing; command output was not summarized")
claimable = not reasons and isinstance(exit_code, int) and exit_code == 0
return {
"claimable": claimable,
"entry": normalized,
"reasons": reasons,
"verdict": "strong" if claimable else "invalid",
}
def assess_full_suite_claim(
ledger_entries: list[dict] | None,
*,
report_text: str = "",
) -> dict:
"""'Full suite passed' requires zero ignored tests unless explicitly noted."""
entries = [normalize_ledger_entry(e) for e in (ledger_entries or [])]
reasons: list[str] = []
lower = (report_text or "").lower()
if not entries:
return {
"claimable": False,
"verdict": "invalid",
"reasons": ["no validation ledger entries recorded"],
}
entry_assessments = [assess_ledger_entry(e) for e in entries]
invalid = [a for a in entry_assessments if not a["claimable"]]
if invalid:
reasons.extend(invalid[0]["reasons"])
ignored_total = 0
for entry in entries:
ignored = entry.get("ignored_paths") or []
ignored_total += len(ignored)
for item in ignored:
path = (item or {}).get("path") if isinstance(item, dict) else item
justification = (item or {}).get("justification", "") if isinstance(item, dict) else ""
if path and not _clean(justification):
reasons.append(
f"ignored path '{path}' lacks justification for full-suite claim"
)
claims_full_suite = "full suite passed" in lower or "full test suite passed" in lower
if claims_full_suite and ignored_total:
except_phrases = (
"full suite except",
"except ignored",
"ignored paths:",
"ignored test",
)
if not any(phrase in lower for phrase in except_phrases):
reasons.append(
"report claims full suite passed but ledger records ignored "
"tests/paths without an explicit 'full suite except …' note"
)
claimable = not reasons and not invalid
return {
"claimable": claimable,
"verdict": "strong" if claimable else ("invalid" if invalid else "weak"),
"reasons": reasons,
"entry_assessments": entry_assessments,
"ignored_path_count": ignored_total,
}
def new_ledger_entry(
*,
command: str,
cwd: str,
exit_code: int,
output_summary: str,
skipped_tests: list[str] | None = None,
ignored_paths: list[dict] | None = None,
timestamp: str | None = None,
) -> dict:
"""Build a ledger entry with an ISO-8601 UTC timestamp."""
ts = timestamp or datetime.now(timezone.utc).replace(microsecond=0).isoformat()
return normalize_ledger_entry({
"command": command,
"cwd": cwd,
"exit_code": exit_code,
"output_summary": output_summary,
"timestamp": ts,
"skipped_tests": skipped_tests or [],
"ignored_paths": ignored_paths or [],
})
def verify_final_report(report: dict | None) -> dict:
"""Verify a structured final report against ledger and workflow proofs."""
data = dict(report or {})
reasons: list[str] = []
contradictions: list[str] = []
identity = _clean(data.get("identity"))
profile = _clean(data.get("profile"))
capability_resolved = data.get("capability_resolved")
worktree_path = _clean(data.get("worktree_path"))
changed_files = data.get("changed_files") or []
ledger_entries = data.get("validation_ledger") or data.get("ledger_entries") or []
if not identity:
reasons.append("identity missing from final report")
if not profile:
reasons.append("profile missing from final report")
if capability_resolved is not True:
reasons.append("capability_resolved must be true with exact resolver evidence")
if not worktree_path:
reasons.append("worktree_path missing from final report")
if not isinstance(changed_files, list):
reasons.append("changed_files must be a list (use [] when none)")
ledger_verdict = assess_full_suite_claim(
ledger_entries,
report_text=_clean(data.get("report_text")),
)
if not ledger_verdict["claimable"]:
reasons.extend(ledger_verdict["reasons"])
validation_claim = _clean(data.get("validation_claim")).lower()
if validation_claim in ("passed", "pass", "full suite passed"):
if ledger_verdict["verdict"] == "invalid":
contradictions.append(
"validation_claim says passed but ledger entries are invalid"
)
review_submitted = data.get("review_submitted")
review_visible_approved = data.get("review_visible_approved")
review_pending = data.get("review_pending")
if review_visible_approved is True and review_pending is True:
contradictions.append(
"review_visible_approved and review_pending cannot both be true"
)
if data.get("claims_approved_review") is True:
if review_visible_approved is not True:
contradictions.append(
"report claims APPROVED review but review_visible_approved is not true"
)
if review_pending is True:
contradictions.append(
"report claims APPROVED review while review_pending is true"
)
merge_performed = data.get("merge_performed")
merge_blocker = _clean(data.get("merge_blocker"))
if merge_performed is True and merge_blocker:
contradictions.append("merge_performed true but merge_blocker also set")
if merge_performed is not True and not merge_blocker and data.get("claims_merged") is True:
contradictions.append("report claims merge but merge_performed is not true")
workspace_clean = data.get("workspace_clean")
workspace_classification = _clean(data.get("workspace_classification"))
if workspace_clean is False and not workspace_classification:
reasons.append(
"workspace is not clean but workspace_classification is missing"
)
if review_submitted is False and data.get("claims_review_submitted") is True:
contradictions.append(
"claims_review_submitted true but review_submitted is false"
)
all_reasons = reasons + contradictions
proven = not all_reasons
return {
"proven": proven,
"block": not proven,
"reasons": all_reasons,
"contradictions": contradictions,
"ledger_verdict": ledger_verdict,
}