Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/sync-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ workflows:
- source: .github/workflows/maint-76-claude-code-review.yml
description: "Claude Code review (opt-in) - runs only on labeled PRs or manual dispatch"

- source: .github/workflows/maint-87-docs-drift-fix-agent.yml
description: "Docs drift fix agent - reports deterministic documentation drift weekly and creates idempotent repair issues only on an explicit apply dispatch"
template_sync: exact

# Renovate configuration - extends the shared fleet preset (renovate-presets/fleet.json).
# create_only so a consumer that already onboarded (e.g. a Mend generic config) is not clobbered;
# supersedes per-repo Renovate onboarding by giving consumers the fleet preset (dev-tool exclusions
Expand Down Expand Up @@ -222,6 +226,15 @@ scripts:
- source: scripts/check_agents_md_freshness.py
description: "Warns when the generated Orchestrator AGENTS.md playbook section cites stale repo paths or commands"
template_sync: exact
- source: scripts/check_docs_drift.py
description: "Detects workflow-inventory and repository-path documentation drift for the docs drift fix agent"
template_sync: exact
- source: scripts/docs_drift_fix_agent.py
description: "Builds bounded deterministic documentation-drift repair plans and idempotent issue batches"
template_sync: exact
- source: config/source_of_truth_docs.yml
description: "Defines the canonical documentation paths scanned by the docs drift fix agent"
template_sync: exact
- source: .github/scripts/issue_format.py
description: "Pure-stdlib validator for AGENT_ISSUE_FORMAT compliance. Single fleet definition of agent-processable; used by agents-issue-format-guard.yml and callable directly by local filers to pre-flight before gh issue create (non-zero exit = unfit). Do not fork per repo."
template_sync: exact
Expand Down
113 changes: 113 additions & 0 deletions .github/workflows/maint-87-docs-drift-fix-agent.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: Maint 87 Docs Drift Fix Agent
Comment thread
stranske marked this conversation as resolved.
Comment thread
stranske marked this conversation as resolved.

# The missing CALLER for scripts/docs_drift_fix_agent.py.
#
# That script has existed and been selftested for months, but nothing invoked it: it appears in no
# workflow and in no external caller, so it has never produced a repair batch. Maint 48 seeds a
# monthly issue asking a lane agent to diff the docs by hand; this runs the DETERMINISTIC check
# instead and turns its output into bounded, agent-ready repair batches.
#
# The two are complementary, not duplicates. Maint 48 catches semantic rot an LLM must judge
# ("this claim is no longer true"). This catches mechanical drift the checker proves
# (dangling refs, workflow-inventory omissions) and is cheap enough to run weekly.
#
# REPORT-ONLY by default. `--apply` creates one issue per repair batch and is reachable only via
# workflow_dispatch, so a scheduled run can never open issues on its own. The script never edits
# repository files in either mode.

on:
schedule:
- cron: '0 8 * * 1' # Mondays 08:00 UTC, after the weekly maintenance cluster
workflow_dispatch:
inputs:
apply:
description: 'Create one GitHub issue per repair batch'
type: boolean
default: false

permissions:
contents: read
issues: write

concurrency:
group: >-
${{ github.workflow }}-${{ github.ref }}-${{
github.event_name == 'workflow_dispatch' && inputs.apply && 'apply' || 'plan'
}}
# Never interrupt issue creation; a report-only run may still supersede an older report.
cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.apply) }}

jobs:
docs-drift-fix-agent:
name: Build bounded docs-drift repair batches
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.12'
Comment thread
stranske marked this conversation as resolved.

- name: Install runtime dependency
run: python -m pip install "pyyaml==6.0.3"

- name: Build repair plan (report-only)
id: plan
run: |
set -euo pipefail
set +e
python3 scripts/docs_drift_fix_agent.py \
--repo "${GITHUB_REPOSITORY}" \
--out-dir docs-drift-plan \
--json > docs-drift-plan.json
agent_status=$?
set -e
if [ "${agent_status}" -gt 1 ]; then
exit "${agent_status}"
fi
cat docs-drift-plan.json
findings=$(python3 -c "import json;print(json.load(open('docs-drift-plan.json'))['finding_count'])")
batches=$(python3 -c "import json;print(json.load(open('docs-drift-plan.json'))['batch_count'])")
echo "findings=${findings}" >> "$GITHUB_OUTPUT"
echo "batches=${batches}" >> "$GITHUB_OUTPUT"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
echo "### Docs-drift fix agent"
echo ""
echo "- findings: ${findings}"
echo "- repair batches: ${batches}"
echo ""
if [ "${findings}" = "0" ]; then
echo "No deterministic docs drift. This is the healthy state, not a skipped run."
fi
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload repair plan
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docs-drift-plan
path: |
docs-drift-plan.json
docs-drift-plan/
if-no-files-found: warn
retention-days: 14

# Issue creation is dispatch-only AND requires findings, so a scheduled run never opens
# issues and a clean tree never opens an empty one.
- name: Create repair issues (dispatch-only)
if: >-
github.event_name == 'workflow_dispatch' &&
inputs.apply &&
steps.plan.outputs.findings != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
python3 scripts/docs_drift_fix_agent.py \
--repo "${GITHUB_REPOSITORY}" \
--out-dir docs-drift-plan \
--apply
1 change: 1 addition & 0 deletions docs/WORKFLOW_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ _Inline Gate helper_
- **`maint-85-keepalive-durability-export.yml`** — Weekly + manual Workflows-owned export that scans recently merged `agents:keepalive` PRs after the grace window, classifies them as durable/reverted/reopened, and uploads safe `langsmith-fleet/v1` durability records. It is evidence export only, not a merge gate.

### Health & Maintenance Highlights
- **`maint-87-docs-drift-fix-agent.yml`** — Runs the deterministic docs-drift detector weekly, uploads bounded repair plans, and permits idempotent issue creation only from an explicit `apply` dispatch. Apply runs use a non-cancelling concurrency lane so an interrupted workflow cannot leave duplicate repair issues; the workflow and its runtime files are exact-synced to consumers.
- **`health-72-template-sync.yml`** — Guards manifest-declared exact template-sync files between the repo and the consumer template. On PRs it auto-runs `scripts/sync_templates.sh` (when the PR comes from this repo), commits/pushes template changes, and then `scripts/validate_template_sync.py` enforces parity; on `push` it just runs the validator. The workflow uses the default installation token; no extra GitHub App mint is needed.
- **`agents-keepalive-branch-sync.yml`** — Dispatch-triggered utility that syncs PR branches with their base branch (merges base into head). It still selects an App token/PAT for git pushes because keepalive needs to merge into automation-owned branches, but all logic stays confined to git + summary updates—no extra API calls beyond pushing the merge.
- **`agents-keepalive-dispatch-handler.yml`** — Repository-dispatch handler that receives `codex-pr-comment-command` payloads, selects a write-capable token (App → PAT → installation), and runs `keepalive_post_work.js` to apply sync-required labels, rerun keepalive legs, or emit debugging breadcrumbs. Token selection remains by design because the handler must write to PRs immediately after the dispatch event.
Expand Down
1 change: 1 addition & 0 deletions docs/ci/WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ The gate uses the shared `.github/scripts/detect-changes.js` helper to decide wh
* [`maint-65-sync-label-docs.yml`](../../.github/workflows/maint-65-sync-label-docs.yml) synchronizes `docs/LABELS.md` to consumer repositories weekly (Sundays 00:00 UTC) or via manual dispatch.
* [`maint-66-monthly-audit.yml`](../../.github/workflows/maint-66-monthly-audit.yml) performs comprehensive monthly workflow health audits, collecting statistics and creating actionable tracking issues.
* [`maint-48-docs-drift-audit.yml`](../../.github/workflows/maint-48-docs-drift-audit.yml) seeds a monthly scoped docs-drift audit issue (deduped) for the lane fleet to diff the canonical docs against reality and open fix PRs.
* [`maint-87-docs-drift-fix-agent.yml`](../../.github/workflows/maint-87-docs-drift-fix-agent.yml) runs the deterministic docs-drift check weekly and turns its output into bounded, agent-ready repair batches via `scripts/docs_drift_fix_agent.py`. Complements maint-48: that seeds an LLM semantic diff, this proves mechanical drift (dangling refs, workflow-inventory omissions). Scheduled runs are report-only; explicit `apply` dispatches create idempotent repair issues in a non-cancelling concurrency lane.
* [`maint-60-release.yml`](../../.github/workflows/maint-60-release.yml) creates GitHub releases automatically when version tags (`v*`) are pushed.
* [`maint-61-release-please.yml`](../../.github/workflows/maint-61-release-please.yml) runs release-please on pushes to `main`, using the Workflows GitHub App token when configured, to maintain the Conventional Commits-driven Release PR, changelog, tags, and GitHub releases from the manifest seeded at `1.1.2`.
## Agents Control Plane
Expand Down
4 changes: 4 additions & 0 deletions docs/ci/WORKFLOW_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,10 @@ Keep this table handy when you are triaging automation: it confirms which workfl
seeds one scoped docs-drift audit issue per month (1st at 07:00 UTC, deduped)
so the lane fleet diffs the canonical docs against the current tree and opens
fix PRs. Doc-rot is the audit's most pervasive defect class.
- **Maint 87 Docs Drift Fix Agent** – `.github/workflows/maint-87-docs-drift-fix-agent.yml`
runs the deterministic docs-drift detector weekly and uploads bounded repair
plans. Explicit apply dispatches create idempotent issues in a non-cancelling
concurrency lane; scheduled runs remain report-only.
- **Maint 45 Cosmetic Repair** – `.github/workflows/maint-45-cosmetic-repair.yml`
is a manual workflow. It runs pytest and the guardrail fixers, then opens a
labelled PR if changes are needed.
Expand Down
6 changes: 5 additions & 1 deletion renovate-presets/consumer-managed-paths.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"description": "GENERATED by scripts/generate_consumer_renovate_ownership.py -- do not edit by hand. Disables Renovate dependency extraction for the paths that maint-68-sync-consumer-repos.yml overwrites from .github/sync-manifest.yml, and only in the consumer repos where that overwrite actually applies. Without this boundary a consumer's Renovate opens PRs against centrally-copied files (Inv-Man-Intake#838, Manager-Database#1347) that the next sync silently reverts. Renovate stays enabled for create-only/skipped paths the consumer owns, and for every canonical source file in stranske/Workflows, which is the sync source rather than a consumer. Regenerate with `python scripts/generate_consumer_renovate_ownership.py`; `--check` fails on drift and runs in scripts/dev_check.sh.",
"packageRules": [
{
"description": "Maint 68 overwrites these 211 manifest-managed paths in every registered consumer; Renovate edits there are reverted on the next sync.",
"description": "Maint 68 overwrites these 215 manifest-managed paths in every registered consumer; Renovate edits there are reverted on the next sync.",
"matchRepositories": [
"stranske/Collab-Admin",
"stranske/Counter_Risk",
Expand Down Expand Up @@ -143,13 +143,15 @@
".github/workflows/backplane-conformance.yml",
".github/workflows/list-llm-models.yml",
".github/workflows/maint-76-claude-code-review.yml",
".github/workflows/maint-87-docs-drift-fix-agent.yml",
".github/workflows/maint-coverage-guard.yml",
".github/workflows/pr-46-dependency-repair-contract.yml",
".github/workflows/reusable-pr-context.yml",
"CLAUDE.md",
"WORKFLOW_USER_GUIDE.md",
"config/model_registry.json",
"config/model_selection_policy.json",
"config/source_of_truth_docs.yml",
"design-system/PRESENTATION_PATTERNS.md",
"design-system/README.md",
"design-system/components.css",
Expand All @@ -175,11 +177,13 @@
"scripts/autopilot_step_timer.py",
"scripts/check_agents_md_freshness.py",
"scripts/check_deliberate_break.py",
"scripts/check_docs_drift.py",
"scripts/check_test_dependencies.sh",
"scripts/ci_coverage_delta.py",
"scripts/ci_history.py",
"scripts/ci_metrics.py",
"scripts/coverage_history_append.py",
"scripts/docs_drift_fix_agent.py",
"scripts/langchain/_llm_client.py",
"scripts/langchain/capability_check.py",
"scripts/langchain/checklist_utils.py",
Expand Down
6 changes: 3 additions & 3 deletions scripts/check_docs_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,14 @@ def _workflow_token_context(text: str, token_start: int, token_end: int) -> str:
def _is_bare_workflow_reference(
text: str, match: re.Match[str], root_workflows: set[str] | None = None
) -> bool:
if match.group(1) in (root_workflows or set()):
return True

token_start, token_end = match.span(1)
context = _workflow_token_context(text, token_start, token_end)
if NON_ROOT_WORKFLOW_CONTEXT_RE.search(context):
return False

if match.group(1) in (root_workflows or set()):
return True

return token_start > 0 and text[token_start - 1] == "`"


Expand Down
78 changes: 72 additions & 6 deletions scripts/docs_drift_fix_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
from __future__ import annotations

import argparse
import hashlib
import json
import re
import shlex
import subprocess
import sys
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -113,6 +115,8 @@ def findings_from_deterministic_report(report: dict[str, Any]) -> list[Finding]:

def findings_from_scan_json(payload: dict[str, Any], *, repo: str) -> list[Finding]:
"""Extract stale/contradictory semantic drift from docs-drift-scan JSON."""
if not isinstance(payload, Mapping):
raise ValueError("scan JSON must contain a top-level mapping")
findings: list[Finding] = []
for bucket in payload.get("by_repo") or []:
if not isinstance(bucket, dict) or bucket.get("repo") != repo:
Expand Down Expand Up @@ -178,7 +182,7 @@ def batch_findings(
def _docs_arg(docs: Sequence[str] | None) -> str:
if not docs:
return ""
return " --docs " + " ".join(docs)
return " --docs " + " ".join(shlex.quote(doc) for doc in docs)


def verification_commands(docs: Sequence[str] | None = None) -> tuple[str, ...]:
Expand Down Expand Up @@ -320,15 +324,29 @@ def build_issue_body(
def load_scan_json(path: Path | None) -> dict[str, Any]:
if path is None:
return {"by_repo": []}
return json.loads(path.read_text(encoding="utf-8"))
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, Mapping):
raise ValueError("scan JSON must contain a top-level mapping")
return dict(payload)


def default_docs_from_config(repo_root: Path, *, repo: str = DEFAULT_REPO) -> list[str]:
config_path = repo_root / DEFAULT_DOCS_CONFIG
if not config_path.is_file():
return list(check_docs_drift.DEFAULT_DOCS)
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
repo_config = (data.get("repos") or {}).get(repo) or {}
if not isinstance(data, Mapping):
raise ValueError("docs config must contain a top-level mapping")
repos = data.get("repos", {})
if repos is None:
repos = {}
if not isinstance(repos, Mapping):
raise ValueError("docs config 'repos' must be a mapping")
repo_config = repos.get(repo, {})
if repo_config is None:
repo_config = {}
if not isinstance(repo_config, Mapping):
raise ValueError(f"docs config entry for {repo!r} must be a mapping")
docs = [
str(item.get("path"))
for item in repo_config.get("docs") or []
Expand Down Expand Up @@ -407,7 +425,54 @@ def write_plan_outputs(plan: dict[str, Any], out_dir: Path) -> None:

def apply_issues(plan: dict[str, Any]) -> list[dict[str, Any]]:
created: list[dict[str, Any]] = []
existing_by_marker: dict[str, str] = {}
for batch in plan["batches"]:
issue_body = batch["issue_body"]
digest = hashlib.sha256(f"{batch['issue_title']}\0{issue_body}".encode()).hexdigest()[:16]
marker = f"<!-- docs-drift-fix-agent:{digest} -->"
list_result = subprocess.run(
[
"gh",
"issue",
"list",
"--repo",
plan["repo"],
"--state",
"open",
"--label",
"documentation",
"--search",
f'"{marker}" in:body',
"--limit",
"1",
"--json",
"body,url",
],
capture_output=True,
text=True,
check=False,
timeout=60,
)
if list_result.returncode != 0:
raise RuntimeError(f"gh issue list failed: {list_result.stderr.strip()}")
matches = json.loads(list_result.stdout or "[]")
if not isinstance(matches, list):
raise ValueError("gh issue list returned a non-list payload")
for row in matches:
if isinstance(row, Mapping) and marker in str(row.get("body") or ""):
existing_by_marker[marker] = str(row.get("url") or "")
break
if marker in existing_by_marker:
created.append(
{
"batch_id": batch["batch_id"],
"disposition": "already-open",
"returncode": 0,
"stdout": existing_by_marker[marker],
"stderr": "",
}
)
continue
result = subprocess.run(
[
"gh",
Expand All @@ -418,7 +483,7 @@ def apply_issues(plan: dict[str, Any]) -> list[dict[str, Any]]:
"--title",
batch["issue_title"],
"--body",
batch["issue_body"],
f"{issue_body.rstrip()}\n\n{marker}\n",
"--label",
"documentation",
],
Expand All @@ -430,6 +495,7 @@ def apply_issues(plan: dict[str, Any]) -> list[dict[str, Any]]:
created.append(
{
"batch_id": batch["batch_id"],
"disposition": "created",
"returncode": result.returncode,
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip(),
Expand Down Expand Up @@ -503,7 +569,7 @@ def main(argv: Sequence[str] | None = None) -> int:
print(json.dumps(plan, indent=2))
else:
print(format_summary(plan, out_dir))
return 1 if plan["finding_count"] else 0
return 0 if args.apply else 1 if plan["finding_count"] else 0
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
Expand Down
Loading
Loading