Compare commits

..
Author SHA1 Message Date
sysadminandClaude Opus 4.8 a55e93763b docs: add agent temp-artifact cleanup runbook and ignore patterns (closes #261)
Failed subagent runs left throwaway helpers (_encode_commit_payload.py,
_emit_payload.py) in the shared working tree, polluting git status and
tripping gitea_lock_issue / pre-flight purity gates.

Add an 'Agent temp artifact cleanup' runbook section requiring deletion
of _encode_*/_emit_*/_inline_* helpers when the MCP commit completes or
aborts, prefer scratchpad payload preparation, and document the cleanup
checklist. Back it with .gitignore patterns so strays cannot block
issue locks for later sessions, and pin both with doc-contract tests
(including a repo-root scan asserting the artifacts stay removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:46:08 -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 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
6 changed files with 246 additions and 15 deletions
+5
View File
@@ -10,3 +10,8 @@ gitea-mcp*.json
.vscode/
graphify-out/
branches/
# Throwaway agent payload/encoding helpers (#261): delete after the MCP
# commit completes or aborts; ignored here so strays cannot block issue locks.
_encode_*.py
_emit_*.py
_inline_*.py
+27
View File
@@ -459,6 +459,33 @@ touching anything.
files, detected secret, or any production/deploy behavior — **stop, report the
blocker, and take no mutating action.** Fail closed; never work around a gate.
## Agent temp artifact cleanup (#261)
Failed or aborted subagent runs have left throwaway helper scripts in the
shared working tree — for example `_encode_commit_payload.py` and
`_emit_payload.py` from the #152 commit-recovery attempt. These artifacts are
never part of any issue scope. They pollute `git status`, which trips
fail-closed gates: `gitea_lock_issue` refuses to lock over tracked edits, and
the pre-flight purity checks attribute the dirt to the current session.
Rules:
- **Delete throwaway helpers as soon as the MCP commit completes or aborts.**
Any file matching `_encode_*`, `_emit_*`, or `_inline_*` created as a
scratch payload/encoding helper must be removed in the same session that
created it — success and failure paths alike.
- **Prefer the scratchpad.** Payload preparation belongs in the session
scratchpad or a temp directory, never inside the repository worktree
(see the shell-free commit payload work in #263).
- **`.gitignore` backstop.** The patterns `_encode_*.py`, `_emit_*.py`, and
`_inline_*.py` are ignored so a leftover helper cannot show up as an
untracked file and block issue locks for later sessions. Ignoring them is a
safety net, not permission to leave them behind.
- **Cleanup checklist before ending any author session:** run
`git status --porcelain` in the worktree you mutated; remove any
`_encode_*` / `_emit_*` / `_inline_*` files you created; report the removal
in the final run report like any other local mutation.
## Task/role alignment (#167)
The **requested task** decides what a session may do — not the credential it
+89
View File
@@ -4103,6 +4103,90 @@ def gitea_get_profile(
return result
_RUNTIME_CAPABILITY_TASKS = (
"create_issue",
"comment_issue",
"create_pr",
"review_pr",
"merge_pr",
"close_issue",
)
def _matching_configured_profiles(
config: dict | None,
required_permission: str,
) -> list[str]:
"""Profile names that allow *required_permission* (redacted metadata only)."""
if not config or "profiles" not in config:
return []
matches: list[str] = []
for p_name, p_data in config["profiles"].items():
if not p_data.get("enabled", True):
continue
p_allowed = p_data.get("allowed_operations") or []
p_forbidden = p_data.get("forbidden_operations") or []
p_allowed_n = []
for op in p_allowed:
try:
p_allowed_n.append(gitea_config.normalize_operation(op))
except Exception:
pass
p_forbidden_n = []
for op in p_forbidden:
try:
p_forbidden_n.append(gitea_config.normalize_operation(op))
except Exception:
pass
ok, _ = gitea_config.check_operation(
required_permission, p_allowed_n, p_forbidden_n
)
if ok:
matches.append(p_name)
return sorted(matches)
def _build_runtime_task_capabilities(
allowed: list[str],
forbidden: list[str],
config: dict | None,
) -> dict:
"""Per-task capability summary for role-aware runtime context (#139)."""
task_entries = []
flags: dict[str, bool] = {}
flag_keys = {
"create_issue": "can_create_issues",
"comment_issue": "can_comment_on_issues",
"create_pr": "can_author_prs",
"review_pr": "can_review_prs",
"merge_pr": "can_merge_prs",
"close_issue": "can_close_issues",
}
for task in _RUNTIME_CAPABILITY_TASKS:
permission = task_capability_map.required_permission(task)
allowed_here, _ = gitea_config.check_operation(
permission, allowed, forbidden
)
entry = {
"task": task,
"required_permission": permission,
"required_role_kind": task_capability_map.required_role(task),
"allowed_in_current_session": allowed_here,
"matching_configured_profiles": _matching_configured_profiles(
config, permission
),
}
task_entries.append(entry)
flag_name = flag_keys.get(task)
if flag_name:
flags[flag_name] = allowed_here
return {
**flags,
"issue_comment_not_implied_by_pr_comment": True,
"task_capabilities": task_entries,
}
@mcp.tool()
def gitea_get_runtime_context(
remote: str = "dadeschools",
@@ -4190,6 +4274,10 @@ def gitea_get_runtime_context(
"or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
)
session_capabilities = _build_runtime_task_capabilities(
allowed, forbidden, config
)
preflight = assess_preflight_status()
if not preflight["preflight_ready"]:
safe_next_action = (
@@ -4214,6 +4302,7 @@ def gitea_get_runtime_context(
"safe_next_action": safe_next_action,
"preflight_ready": preflight["preflight_ready"],
"preflight_block_reasons": preflight["preflight_block_reasons"],
"session_capabilities": session_capabilities,
}
if reveal and h:
+18 -13
View File
@@ -2282,18 +2282,22 @@ def assess_issue_filing_mutation_scope(
"comment_issue", "create_issue",
):
continue
if absent in text and f"no {absent}" not in text:
if any(
phrase in text
for phrase in (
f"no {absent}",
f"no {absent}s",
f"without {absent}",
"none performed",
"none.",
)
):
continue
if absent not in text:
continue
denial_phrases = (
f"no {absent}",
f"no {absent}s",
f"without {absent}",
"none performed",
"none.",
"confirm no",
)
if any(phrase in text for phrase in denial_phrases):
continue
reasons.append(
f"single-mutation run mentions '{absent}' without "
f"confirming it was not performed"
)
if performed == ["create_issue"]:
for check in ("label", "comment", "pr", "review", "merge"):
if check in text and f"no {check}" not in text:
@@ -2424,7 +2428,8 @@ def assess_issue_filing_final_report(
reasons = []
downgraded = False
for name, result in checks.items():
if result.get("verdict") == "missing":
verdict = result.get("verdict")
if verdict in ("missing", "incomplete"):
downgraded = True
reasons.extend(result.get("reasons") or [])
elif result.get("downgraded") or not result.get("complete", True):
@@ -0,0 +1,76 @@
"""Docs checks for agent temp-artifact cleanup guidance (#261).
Failed subagent runs left throwaway helper scripts (e.g.
``_encode_commit_payload.py``, ``_emit_payload.py``) in the shared working
tree, polluting ``git status`` and tripping issue-lock / preflight gates.
These tests keep the cleanup runbook guidance present and the ignore
patterns in place so stray helpers can never dirty a worktree again.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md"
GITIGNORE = REPO_ROOT / ".gitignore"
ARTIFACT_PATTERNS = ("_encode_*.py", "_emit_*.py", "_inline_*.py")
def _runbook_text():
assert RUNBOOK.is_file(), "missing docs/llm-workflow-runbooks.md"
return RUNBOOK.read_text(encoding="utf-8")
def _gitignore_text():
assert GITIGNORE.is_file(), "missing .gitignore"
return GITIGNORE.read_text(encoding="utf-8")
def test_runbook_has_agent_temp_artifact_cleanup_section():
text = _runbook_text()
assert "## Agent temp artifact cleanup" in text, (
"runbook lacks an 'Agent temp artifact cleanup' section")
assert "#261" in text, "cleanup guidance does not reference issue #261"
def test_runbook_names_known_stray_artifacts():
text = _runbook_text()
for name in ("_encode_commit_payload.py", "_emit_payload.py"):
assert name in text, f"runbook does not name stray artifact {name!r}"
def test_runbook_covers_all_artifact_patterns():
text = _runbook_text()
for pattern in ("_encode_*", "_emit_*", "_inline_*"):
assert pattern in text, f"runbook does not cover pattern {pattern!r}"
def test_runbook_requires_cleanup_after_commit_or_abort():
text = _runbook_text().lower()
assert "delete" in text or "remove" in text
for phase in ("completes", "aborts"):
assert phase in text, (
f"runbook does not require cleanup when the MCP commit {phase}")
def test_runbook_explains_lock_and_preflight_impact():
text = _runbook_text().lower()
assert "gitea_lock_issue" in text
assert "preflight" in text or "pre-flight" in text
def test_gitignore_blocks_agent_helper_patterns():
lines = [
ln.strip() for ln in _gitignore_text().splitlines()
if ln.strip() and not ln.strip().startswith("#")
]
for pattern in ARTIFACT_PATTERNS:
assert pattern in lines, f".gitignore lacks pattern {pattern!r}"
def test_no_stray_agent_helpers_in_repo_root():
"""Acceptance: artifacts stay removed from default worktrees."""
stray = [
p.name for pattern in ARTIFACT_PATTERNS
for p in REPO_ROOT.glob(pattern)
]
assert not stray, f"stray agent helper artifacts present: {stray}"
+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