Implements the Phase 2a Echodo bridge described in agent-pipeline's v0.4 plan (.cursor/plans/pipeline_v0.4_design_+_echodo_54a3bdb7). Echodo becomes the projection layer over the local .convoys/ tree; local files remain source of truth per the local-first contract. MCP lifecycle tools (apps/mcp-server/src/tools/): - create-convoy.ts: registers create_convoy. Creates a Drizzle `project` object with status="draft" + appends initial "## Status log" to the description. Takes workspace_slug + slug + title + classification + skip_flags + success_metric + idea_markdown + repo. - create-brief.ts: registers create_brief. Creates a `task` child of the convoy project. Takes convoyId + briefNumber + title + files_allowlist + depends_on + acceptance_criteria + brief_markdown. - transition-convoy-status.ts: registers transition_convoy_status. Enforces the 9-status state machine from plan §7.3 with valid transitions + actor-permission gates. Appends an audit entry to the description's ## Status log per transition. - log-convoy-event.ts: registers log_convoy_event. Inserts events into the new convoy_events table (one row per role hand-off, with classification, skip-flags, duration, stack-class, outcome, multitask-group metadata). - query-manifest-status.ts: registers query_manifest_status (stub — depends on the Phase 4 pipeline_drift_reports table; documented). - reconcile-from-files.ts: registers reconcile_from_files implementing the local-first recovery path. Reads .convoys/.pending-mcp-sync.jsonl from the given repoPath, replays queued log_convoy_event + transition_convoy_status calls, updates the outbox file on success/failure. Resolves the SPOF risk: failed MCP calls during offline windows reconcile when the bridge returns. Database (packages/database/): - src/schema/convoy_events.ts: new Drizzle schema. Columns: id, workspaceId, convoyId, convoySlug, role, brief, classification, skipFlags, durationS, stackClass, repo, outcome, multitaskGroup, metadata, ts. 4 indexes for per-workspace + per-convoy + role-filtered reads. - src/schema/index.ts: re-exports the new table. - migrations/0010_wandering_the_professor.sql + meta snapshot: generated via drizzle-kit generate. Pure-additive (CREATE TABLE + indexes + FKs). - package.json: db:migrate / db:push / db:studio now use node --env-file to load ../../.env (consistent with the existing mcp-server tsx pattern). db:generate stays as-is (offline operation, no env needed). L1 + L3 agent-pipeline scaffolding installed per agent-pipeline/skills/bootstrap-agent-context v0.5.0: - .agent-context-manifest.yml: tracks 17 artifacts at pipeline version 0.5.0 with sha256 hashes. 4 artifacts flagged customized:true (no-go-zones, CODEOWNERS, pr-health-rollup.yml, echodo.config.json) — adapted from templates for Echodo's monorepo + Drizzle + Coolify + workspace_slug. - .convoys/README.md: explains convoy file convention. - .cursor/agents/echodo.config.json: workspace_slug=convoys-tasks (the workspace created in Echodo UI). Fallback policy: local-only. - .cursor/rules/no-go-zones.mdc: adapted for Drizzle migrations, Coolify deploy infra, mcp-server boundaries. - .github/CODEOWNERS: @rstillw as sole maintainer; targeted rules for apps/, packages/, auth, deploy infra, DB schema, MCP server, agent context. - .github/PULL_REQUEST_TEMPLATE.md: pipeline PR template. - .github/workflows/agent-context-drift.yml: drift monitor against upstream agent-pipeline. - .github/workflows/pr-health-rollup.yml: adapted for tasks' pnpm monorepo + Coolify deploy (no per-PR preview by default). - scripts/log-convoy-event.sh: convoy event logger shim. - scripts/wt.sh: worktree helper stub (deprecated — points at Cursor 3.2 native worktrees). - .gitignore: excludes .convoys/.metrics.jsonl + .convoys/.pending-mcp-sync.jsonl (local agent analytics + MCP outbox). Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
6.6 KiB
YAML
164 lines
6.6 KiB
YAML
# agent-context drift detection
|
|
#
|
|
# Weekly + on-demand check: does this repo's installed agent-pipeline
|
|
# artifacts match the latest upstream pipeline release?
|
|
#
|
|
# - Reads .agent-context-manifest.yml (committed at repo root)
|
|
# - Clones the pipeline repo at its latest tag
|
|
# - Compares each tracked artifact's installed_hash to the pipeline source hash
|
|
# - Compares manifest pipeline_version to pipeline version.txt
|
|
# - Opens (or updates) an issue titled "agent-context: N files behind v<X>"
|
|
# if drift is detected
|
|
#
|
|
# No auto-fix. The fix workflow is: a human runs `sync-agent-context` in
|
|
# Cursor and reviews per-file diffs.
|
|
|
|
name: agent-context-drift
|
|
|
|
on:
|
|
schedule:
|
|
# Mondays at 13:00 UTC. Adjust to taste.
|
|
- cron: "0 13 * * 1"
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
issues: write
|
|
|
|
jobs:
|
|
drift:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout consumer repo
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Read manifest
|
|
id: manifest
|
|
run: |
|
|
if [ ! -f .agent-context-manifest.yml ]; then
|
|
echo "::warning::No .agent-context-manifest.yml — agent-pipeline not installed or pre-v0.3.0. Skipping drift check."
|
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
INSTALLED=$(grep -E '^pipeline_version:' .agent-context-manifest.yml | head -1 | sed -E 's/.*"(.*)".*/\1/')
|
|
SOURCE=$(grep -E '^pipeline_source:' .agent-context-manifest.yml | head -1 | sed -E 's/.*"(.*)".*/\1/')
|
|
echo "installed_version=$INSTALLED" >> "$GITHUB_OUTPUT"
|
|
echo "pipeline_source=$SOURCE" >> "$GITHUB_OUTPUT"
|
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Clone pipeline at latest tag
|
|
if: steps.manifest.outputs.skip != 'true'
|
|
id: pipeline
|
|
run: |
|
|
PIPELINE_URL="${{ steps.manifest.outputs.pipeline_source }}"
|
|
# Convert HTTPS URL → clone target. Already in HTTPS form.
|
|
mkdir -p /tmp/pipeline
|
|
git clone --depth 50 "$PIPELINE_URL" /tmp/pipeline
|
|
cd /tmp/pipeline
|
|
LATEST_TAG=$(git tag --sort=-v:refname | head -1)
|
|
if [ -z "$LATEST_TAG" ]; then
|
|
echo "::warning::Pipeline repo has no tags. Comparing against main."
|
|
LATEST_TAG="main"
|
|
fi
|
|
git checkout "$LATEST_TAG"
|
|
PIPELINE_VER=$(cat version.txt | tr -d '[:space:]')
|
|
echo "tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
|
|
echo "version=$PIPELINE_VER" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Compute drift
|
|
if: steps.manifest.outputs.skip != 'true'
|
|
id: drift
|
|
run: |
|
|
INSTALLED="${{ steps.manifest.outputs.installed_version }}"
|
|
UPSTREAM="${{ steps.pipeline.outputs.version }}"
|
|
BEHIND=0
|
|
CUSTOMIZED=0
|
|
CONFLICT=0
|
|
|
|
# Walk manifest artifacts. For each, compare installed_hash to local
|
|
# current hash, and pipeline source hash to installed_hash.
|
|
# YAML parsing in bash is intentionally minimal — relies on the
|
|
# bootstrap skill emitting a predictable shape.
|
|
python3 - <<'PY' >> drift-report.md
|
|
import hashlib, sys, yaml, os
|
|
|
|
def sha(path):
|
|
if not os.path.exists(path):
|
|
return None
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(8192), b""):
|
|
h.update(chunk)
|
|
return "sha256:" + h.hexdigest()
|
|
|
|
with open(".agent-context-manifest.yml") as f:
|
|
m = yaml.safe_load(f)
|
|
|
|
counts = {"behind": [], "customized": [], "conflict": [], "deleted": []}
|
|
for art in m.get("artifacts", []):
|
|
local_hash = sha(art["path"])
|
|
pipe_hash = sha(os.path.join("/tmp/pipeline", art["source"]))
|
|
if local_hash is None:
|
|
counts["deleted"].append(art["path"])
|
|
continue
|
|
local_matches = local_hash == art["installed_hash"]
|
|
pipe_changed = pipe_hash is not None and pipe_hash != art["installed_hash"]
|
|
if local_matches and pipe_changed:
|
|
counts["behind"].append(art["path"])
|
|
elif not local_matches and pipe_changed:
|
|
counts["conflict"].append(art["path"])
|
|
elif not local_matches:
|
|
counts["customized"].append(art["path"])
|
|
|
|
print("# agent-context drift report")
|
|
print(f"\nInstalled: `{m.get('pipeline_version')}` · Upstream: `${{ steps.pipeline.outputs.version }}` (`${{ steps.pipeline.outputs.tag }}`)")
|
|
for kind in ("behind", "conflict", "customized", "deleted"):
|
|
files = counts[kind]
|
|
if files:
|
|
print(f"\n## {kind} ({len(files)})")
|
|
for f in files:
|
|
print(f"- `{f}`")
|
|
PY
|
|
|
|
BEHIND=$(grep -c '^## behind' drift-report.md || echo 0)
|
|
CONFLICT=$(grep -c '^## conflict' drift-report.md || echo 0)
|
|
NEED_ISSUE="false"
|
|
if [ "$INSTALLED" != "$UPSTREAM" ] || [ "$BEHIND" -gt 0 ] || [ "$CONFLICT" -gt 0 ]; then
|
|
NEED_ISSUE="true"
|
|
fi
|
|
echo "need_issue=$NEED_ISSUE" >> "$GITHUB_OUTPUT"
|
|
echo "upstream_version=$UPSTREAM" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Open / update drift issue
|
|
if: steps.manifest.outputs.skip != 'true' && steps.drift.outputs.need_issue == 'true'
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
const fs = require('fs');
|
|
const body = fs.readFileSync('drift-report.md', 'utf8') +
|
|
'\n\n---\n\n_To resolve: open this repo in Cursor and ask **"Sync agent context for this repo"**. The sync skill walks the diff per file._';
|
|
const title = `agent-context: behind ${{ steps.drift.outputs.upstream_version }}`;
|
|
const existing = await github.rest.issues.listForRepo({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
state: 'open',
|
|
labels: 'agent-context-drift',
|
|
});
|
|
const found = existing.data.find(i => i.title.startsWith('agent-context: behind'));
|
|
if (found) {
|
|
await github.rest.issues.update({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: found.number,
|
|
title,
|
|
body,
|
|
});
|
|
} else {
|
|
await github.rest.issues.create({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
title,
|
|
body,
|
|
labels: ['agent-context-drift'],
|
|
});
|
|
}
|