diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c8ff4249f4..da5e377b59 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,14 +3,7 @@ "image": "mcr.microsoft.com/devcontainers/go:1-bookworm", "customizations": { "vscode": { - "extensions": [ - "golang.go", - "GitHub.copilot-chat", - "GitHub.copilot", - "github.vscode-github-actions", - "astro-build.astro-vscode", - "DavidAnson.vscode-markdownlint" - ] + "extensions": ["golang.go", "GitHub.copilot-chat", "GitHub.copilot", "github.vscode-github-actions", "astro-build.astro-vscode", "DavidAnson.vscode-markdownlint"] }, "codespaces": { "repositories": { diff --git a/.github/workflows/daily-team-status.lock.yml b/.github/workflows/daily-team-status.lock.yml index e5e02d2382..d47aaeba0c 100644 --- a/.github/workflows/daily-team-status.lock.yml +++ b/.github/workflows/daily-team-status.lock.yml @@ -239,7 +239,7 @@ jobs: "type": "array" }, "parent": { - "description": "Parent issue number for creating sub-issues. Can be a real issue number (e.g., 42) or a temporary_id (e.g., 'aw_abc123def456') from a previously created issue in the same workflow run.", + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123def456') from a previously created issue in the same workflow run.", "type": [ "number", "string" diff --git a/actions/setup/js/push_repo_memory.cjs b/actions/setup/js/push_repo_memory.cjs index ccea0798a2..23318e27fd 100644 --- a/actions/setup/js/push_repo_memory.cjs +++ b/actions/setup/js/push_repo_memory.cjs @@ -31,6 +31,104 @@ async function main() { const ghToken = process.env.GH_TOKEN; const githubRunId = process.env.GITHUB_RUN_ID || "unknown"; + /** @param {unknown} value */ + function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + /** @param {string} absPath */ + function tryParseJSONFile(absPath) { + const raw = fs.readFileSync(absPath, "utf8"); + if (!raw.trim()) { + throw new Error(`Empty JSON file: ${absPath}`); + } + try { + return JSON.parse(raw); + } catch (e) { + throw new Error(`Invalid JSON in ${absPath}: ${e instanceof Error ? e.message : String(e)}`); + } + } + + // ============================================================================ + // CAMPAIGN-SPECIFIC VALIDATION FUNCTIONS + // ============================================================================ + // The following functions implement validation for the campaign convention: + // When memoryId is "campaigns" and file-glob matches "/**", + // enforce specific JSON schemas for cursor.json and metrics/*.json files. + // + // This is a domain-specific convention used by Campaign Workflows to maintain + // durable state in repo-memory. See docs/guides/campaigns/ for details. + // ============================================================================ + + /** @param {any} obj @param {string} campaignId @param {string} relPath */ + function validateCampaignCursor(obj, campaignId, relPath) { + if (!isPlainObject(obj)) { + throw new Error(`Cursor must be a JSON object: ${relPath}`); + } + + // Cursor payload is intentionally treated as an opaque checkpoint. + // We only enforce that it is valid JSON and (optionally) self-identifies the campaign. + if (obj.campaign_id !== undefined) { + if (typeof obj.campaign_id !== "string" || obj.campaign_id.trim() === "") { + throw new Error(`Cursor 'campaign_id' must be a non-empty string when present: ${relPath}`); + } + if (obj.campaign_id !== campaignId) { + throw new Error(`Cursor 'campaign_id' must match '${campaignId}' when present: ${relPath}`); + } + } + + // Allow optional date metadata if the cursor chooses to include it. + if (obj.date !== undefined) { + if (typeof obj.date !== "string" || obj.date.trim() === "") { + throw new Error(`Cursor 'date' must be a non-empty string (YYYY-MM-DD) when present: ${relPath}`); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(obj.date)) { + throw new Error(`Cursor 'date' must be YYYY-MM-DD when present: ${relPath}`); + } + } + } + + /** @param {any} obj @param {string} campaignId @param {string} relPath */ + function validateCampaignMetricsSnapshot(obj, campaignId, relPath) { + if (!isPlainObject(obj)) { + throw new Error(`Metrics snapshot must be a JSON object: ${relPath}`); + } + if (typeof obj.campaign_id !== "string" || obj.campaign_id.trim() === "") { + throw new Error(`Metrics snapshot must include non-empty 'campaign_id': ${relPath}`); + } + if (obj.campaign_id !== campaignId) { + throw new Error(`Metrics snapshot 'campaign_id' must match '${campaignId}': ${relPath}`); + } + if (typeof obj.date !== "string" || obj.date.trim() === "") { + throw new Error(`Metrics snapshot must include non-empty 'date' (YYYY-MM-DD): ${relPath}`); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(obj.date)) { + throw new Error(`Metrics snapshot 'date' must be YYYY-MM-DD: ${relPath}`); + } + + // Require these to be present and non-negative integers (aligns with CampaignMetricsSnapshot). + const requiredIntFields = ["tasks_total", "tasks_completed"]; + for (const field of requiredIntFields) { + if (!Number.isInteger(obj[field]) || obj[field] < 0) { + throw new Error(`Metrics snapshot '${field}' must be a non-negative integer: ${relPath}`); + } + } + + // Optional numeric fields, if present. + const optionalIntFields = ["tasks_in_progress", "tasks_blocked"]; + for (const field of optionalIntFields) { + if (obj[field] !== undefined && (!Number.isInteger(obj[field]) || obj[field] < 0)) { + throw new Error(`Metrics snapshot '${field}' must be a non-negative integer when present: ${relPath}`); + } + } + if (obj.velocity_per_day !== undefined && (typeof obj.velocity_per_day !== "number" || obj.velocity_per_day < 0)) { + throw new Error(`Metrics snapshot 'velocity_per_day' must be a non-negative number when present: ${relPath}`); + } + if (obj.estimated_completion !== undefined && typeof obj.estimated_completion !== "string") { + throw new Error(`Metrics snapshot 'estimated_completion' must be a string when present: ${relPath}`); + } + } + // Validate required environment variables if (!artifactDir || !memoryId || !targetRepo || !branchName || !ghToken) { core.setFailed("Missing required environment variables: ARTIFACT_DIR, MEMORY_ID, TARGET_REPO, BRANCH_NAME, GH_TOKEN"); @@ -41,8 +139,32 @@ async function main() { // The artifactDir IS the memory directory (no nested structure needed) const sourceMemoryPath = artifactDir; + // ============================================================================ + // CAMPAIGN MODE DETECTION + // ============================================================================ + // Campaign Workflows use a convention-based pattern in repo-memory: + // - memoryId: "campaigns" + // - file-glob: "/**" + // + // When this pattern is detected, we enforce campaign-specific validation: + // 1. cursor.json must exist and follow the cursor schema + // 2. At least one metrics/*.json file must exist and follow the metrics schema + // + // This ensures campaigns maintain durable state consistency across workflow runs. + // Non-campaign repo-memory configurations bypass this validation entirely. + // ============================================================================ + const singlePattern = fileGlobFilter.trim().split(/\s+/).filter(Boolean); + const campaignPattern = singlePattern.length === 1 ? singlePattern[0] : ""; + const campaignMatch = memoryId === "campaigns" ? /^([^*?]+)\/\*\*$/.exec(campaignPattern) : null; + const campaignId = campaignMatch ? campaignMatch[1].replace(/\/$/, "") : ""; + const isCampaignMode = Boolean(campaignId); + // Check if artifact memory directory exists if (!fs.existsSync(sourceMemoryPath)) { + if (isCampaignMode) { + core.setFailed(`Campaign repo-memory is enabled but no campaign state was written. Expected to find cursor and metrics under: ${sourceMemoryPath}/${campaignId}/`); + return; + } core.info(`Memory directory not found in artifact: ${sourceMemoryPath}`); return; } @@ -90,6 +212,9 @@ async function main() { // Recursively scan and collect files from artifact directory let filesToCopy = []; + // Track campaign files for validation + let campaignCursorFound = false; + let campaignMetricsCount = 0; /** * Recursively scan directory and collect files @@ -139,6 +264,20 @@ async function main() { throw new Error("File size validation failed"); } + // Campaign-specific JSON validation (only when campaign mode is active) + // This enforces the campaign state file schemas for cursor and metrics + if (isCampaignMode && relativeFilePath.startsWith(`${campaignId}/`)) { + if (relativeFilePath === `${campaignId}/cursor.json`) { + const obj = tryParseJSONFile(fullPath); + validateCampaignCursor(obj, campaignId, relativeFilePath); + campaignCursorFound = true; + } else if (relativeFilePath.startsWith(`${campaignId}/metrics/`) && relativeFilePath.endsWith(".json")) { + const obj = tryParseJSONFile(fullPath); + validateCampaignMetricsSnapshot(obj, campaignId, relativeFilePath); + campaignMetricsCount++; + } + } + filesToCopy.push({ relativePath: relativeFilePath, source: fullPath, @@ -155,6 +294,22 @@ async function main() { return; } + // Campaign mode validation: ensure required state files were found + // This enforcement is only active when campaign mode is detected + if (isCampaignMode) { + if (!campaignCursorFound) { + core.error(`Missing required campaign cursor file: ${campaignId}/cursor.json`); + core.setFailed("Campaign cursor validation failed"); + return; + } + + if (campaignMetricsCount === 0) { + core.error(`Missing required campaign metrics snapshots under: ${campaignId}/metrics/*.json`); + core.setFailed("Campaign metrics validation failed"); + return; + } + } + // Validate file count if (filesToCopy.length > maxFileCount) { core.setFailed(`Too many files (${filesToCopy.length} > ${maxFileCount})`); @@ -174,6 +329,14 @@ async function main() { const destDir = path.dirname(destFilePath); try { + // Path traversal protection + const resolvedRoot = path.resolve(destMemoryPath) + path.sep; + const resolvedDest = path.resolve(destFilePath); + if (!resolvedDest.startsWith(resolvedRoot)) { + core.setFailed(`Refusing to write outside repo-memory directory: ${file.relativePath}`); + return; + } + // Ensure destination directory exists fs.mkdirSync(destDir, { recursive: true }); diff --git a/docs/src/content/docs/guides/campaigns.md b/docs/src/content/docs/guides/campaigns.md index 5ec83fbd1d..ee06bad3a2 100644 --- a/docs/src/content/docs/guides/campaigns.md +++ b/docs/src/content/docs/guides/campaigns.md @@ -1,117 +1,45 @@ --- title: "Agentic campaigns" -description: "Run structured, visible automation initiatives with GitHub Agentic Workflows and GitHub Projects." +description: "Run structured, visible delegation initiatives with GitHub Agentic Workflows and GitHub Projects." --- -An agentic campaign is a finite **initiative** with explicit ownership, review gates, and clear tracking. It helps you run large automation efforts—migrations, upgrades, and rollouts—in a way that is structured and visible. +Agentic Campaigns are bounded, goal-driven efforts where agents carry out work over time. -Agentic workflows still do the hands-on work. Agentic campaigns sit above them and add the *initiative layer*: a shared definition of scope, consistent tracking, and standard progress reporting. +In practice, it is the step from automation to delegation. Workflows are already capable of running continuously (scheduled, event-driven, and re-run), and many initiatives should be automated that way. An agentic campaign is the convention that makes that continuous work easy to see, review, and steer toward a specific goal. -If you are deciding whether you need an agentic campaign, start here. +For example, a GitHub Agentic Workflows workflow can run an agent on a schedule, decide whether a repo needs a dependency bump, and then emit a `create_pull_request` safe-output to open the PR. An agentic campaign uses that same kind of agentic workflow as a repeatable worker and adds the coordination layer: it defines the objective and KPIs, applies a tracker label like `campaign:`, keeps a GitHub Project updated, and writes durable progress (a `cursor.json` checkpoint plus `metrics/*.json` snapshots) to repo-memory until the goal is met. -## When to use agentic campaigns +## When to use a campaign -Use an agentic campaign when you need to run a finite initiative and you want it to be easy to review, operate, and report on. +Use a campaign when you care about progress across days or weeks (scope, tracking, and reporting), not just the output of one execution. -Example: "Upgrade a dependency across 50 repositories over two weeks, with an approval gate, daily progress updates, and a final summary." +If what you need is run-level automation with logs, artifacts, and pass/fail, agentic workflows are enough. If what you need is a bounded, goal-driven initiative with a dashboard, a tracker label, and ongoing reporting, you want the campaign pattern. -| If you care about… | Use… | -|---|---| -| The result of each run (success/failure, logs, artifacts) | A regular workflow | -| The overall outcome across many runs, repos, and days/weeks | An agentic campaign | +## What you get -Why just-a-label stops being enough at scale: it does not define scope, it is easy to apply inconsistently, and it does not give you a standard status view. +You get a GitHub Project as the dashboard, a generated orchestrator workflow that keeps that dashboard in sync, and a spec file that makes the effort reviewable (objective, KPIs, governance, and wiring). The orchestrator is just another workflow; campaigns are a way of wiring workflows together around a shared goal. -Use an agentic campaign when any of these are true: +## What it is in the repo -- The work runs for days/weeks and needs handoffs and a durable status view. -- The scope spans many repos/teams and you need a single source of truth. -- You need approvals, staged rollouts, or other explicit decision points. -- You want repeatability: baselines + metrics + learnings for the next run. +A campaign is defined by a spec file and, when needed, a generated orchestrator. The spec lives at `.github/workflows/.campaign.md`. When the spec includes meaningful campaign wiring, compilation also generates `.github/workflows/.campaign.g.md` and compiles it to a `.lock.yml` workflow. -What agentic campaigns add: +The spec is the source of truth for what success means (the objective), how progress is measured (KPIs, with exactly one marked `primary`), where progress is shown (the GitHub Project URL), what participates (the workflows), and what is tracked (the label applied to issues and pull requests, commonly `campaign:`). -- An agentic campaign spec file declares the initiative (Project dashboard URL, tracker label, referenced workflows, and optional memory/metrics locations). -- `gh aw compile` validates the spec and can generate an orchestrator workflow (`.campaign.g.md`). -- The CLI gives consistent inventory and status (`gh aw campaign`, `gh aw campaign status`). +## How it works -You do not need agentic campaigns just to run a workflow across many repositories (or org boundaries). That is primarily an authentication/permissions problem. Agentic campaigns solve definition, validation, and consistent tracking. +Most campaigns follow the same shape. The GitHub Project is the human-facing status view. The orchestrator workflow discovers tracked items and updates the Project. Worker workflows (when you use them) do the real work, such as opening pull requests or applying fixes. -## How agentic campaigns work +Workers stay campaign-agnostic. If you want cross-run discovery of worker-created assets, workers can include a `tracker-id` marker and the orchestrator can search for it. -Once you decide to use an agentic campaign, most implementations follow the same shape: +## Durable state (repo-memory) -- **Orchestrator workflow (generated)**: maintains the campaign dashboard by syncing tracker-labeled issues/PRs to the GitHub Project board, updating status fields, and posting periodic reports. The orchestrator handles both initial discovery and ongoing synchronization. -- **Worker workflows (optional)**: process campaign-labeled issues to do the actual work (open PRs, apply fixes, etc.). Workers include a `tracker-id` so the orchestrator can discover their created assets. +Campaigns become repeatable when they also write durable state to repo-memory (a git branch used for snapshots). The recommended layout is `memory/campaigns//cursor.json` for the checkpoint (treated as an opaque JSON object) and `memory/campaigns//metrics/.json` for append-only metrics snapshots. -You can track agentic campaigns with just labels and issues, but agentic campaigns become much more reusable when you also store baselines, metrics, and learnings in repo-memory (a git branch used for machine-generated snapshots). +Campaign tooling enforces this durability contract at push time: when a campaign writes repo-memory, it must include a cursor and at least one metrics snapshot. -### Orchestrator and Worker Coordination +## Next steps -Agentic campaigns use a **tracker-id** mechanism to coordinate between orchestrators and workers. This architecture maintains clean separation of concerns: workers execute tasks without campaign awareness, while orchestrators manage coordination and tracking. - -#### The Coordination Pattern - -1. **Worker workflows** include a `tracker-id` in their frontmatter (e.g., `tracker-id: "daily-file-diet"`). This identifier is automatically embedded in all assets created by the workflow (issues, PRs, discussions, comments) as an XML comment marker: `` - -2. **Orchestrator workflows** discover work created by workers by searching for issues containing the worker's tracker-id. For example, to find issues created by a worker with `tracker-id: "daily-file-diet"`: - ``` - repo:owner/repo "tracker-id: daily-file-diet" in:body - ``` - -3. The orchestrator then adds discovered issues to the agentic campaign's GitHub Project board and updates their status as work progresses. - -This design allows workers to operate independently without knowledge of the agentic campaign, while orchestrators maintain a centralized view of all campaign work by searching for tracker-id markers. - -#### Orchestrator Workflow Phases - -Generated orchestrator workflows follow a four-phase execution model each time they run: - -**Phase 1: Read State (Discovery)** -- Query for tracker-labeled issues/PRs matching the campaign -- Query for worker-created issues using tracker-id search (if workers are configured) -- Read current state of the GitHub Project board -- Compare discovered items against board state to identify gaps - -**Phase 2: Make Decisions (Planning)** -- Decide which new items to add to the board (respecting governance limits) -- Determine status updates for existing items (respecting governance rules like no-downgrade) -- Check campaign completion criteria - -**Phase 3: Write State (Execution)** -- Add new items to project board via `update-project` safe output -- Update status fields for existing board items -- Record completion state if campaign is done - -**Phase 4: Report (Output)** -- Generate status report summarizing execution -- Record metrics: items discovered, added, updated, skipped -- Report any failures encountered - -#### Core Design Principles - -The orchestrator/worker pattern enforces these principles: - -- **Workers are immutable** - Worker workflows never change based on campaign state -- **Workers are campaign-agnostic** - Workers execute the same way regardless of campaign context -- **Campaign logic is external** - All orchestration happens in the orchestrator, not workers -- **Single source of truth** - The GitHub Project board is the authoritative campaign state -- **Idempotent operations** - Re-execution produces the same result without corruption -- **Governed operations** - Orchestrators respect pacing limits and opt-out policies - -These principles ensure workers can be reused across agentic campaigns and remain simple, while orchestrators handle all coordination complexity. - -## Next Steps - -- **[Campaign Specs](/gh-aw/guides/campaigns/specs/)** - Learn about spec files and configuration -- **[Getting Started](/gh-aw/guides/campaigns/getting-started/)** - Quick start guide and walkthrough -- **[Project Management](/gh-aw/guides/campaigns/project-management/)** - Using GitHub Projects with roadmap views -- **[CLI Commands](/gh-aw/guides/campaigns/cli-commands/)** - Command reference for campaign management - -## Related Patterns - -- **[ResearchPlanAssign](/gh-aw/guides/researchplanassign/)** - Research → generate coordinated work -- **[ProjectOps](/gh-aw/examples/issue-pr-events/projectops/)** - Project board integration for campaigns -- **[MultiRepoOps](/gh-aw/guides/multirepoops/)** - Cross-repository operations -- **[Cache & Memory](/gh-aw/reference/memory/)** - Persistent storage for campaign data -- **[Safe Outputs](/gh-aw/reference/safe-outputs/)** - `create-issue`, `add-comment` for campaigns +- [Getting started](/gh-aw/guides/campaigns/getting-started/) – create a campaign quickly +- [Campaign specs](/gh-aw/guides/campaigns/specs/) – spec fields (objective/KPIs, governance, memory) +- [Project management](/gh-aw/guides/campaigns/project-management/) – project board setup tips +- [CLI commands](/gh-aw/guides/campaigns/cli-commands/) – CLI reference diff --git a/docs/src/content/docs/guides/campaigns/getting-started.md b/docs/src/content/docs/guides/campaigns/getting-started.md index d64a8027d5..eab021be33 100644 --- a/docs/src/content/docs/guides/campaigns/getting-started.md +++ b/docs/src/content/docs/guides/campaigns/getting-started.md @@ -3,76 +3,86 @@ title: "Getting Started" description: "Quick start guide for creating and launching agentic campaigns" --- -This guide walks through the fastest way to create and launch an agentic campaign. +This guide is the shortest path from “we want a campaign” to a working dashboard. -## Quick start +## Quick start (5 steps) -1. Create an agentic campaign spec: `.github/workflows/.campaign.md`. -2. Reference one or more workflows in `workflows:`. -3. Set `project-url` to the org Project v2 URL you use as the agentic campaign dashboard. -4. Add a `tracker-label` so issues/PRs can be queried consistently. -5. Run `gh aw compile` to validate campaign specs and compile workflows. +1. Create a GitHub Project board (manual, one-time) and copy its URL. +2. Add `.github/workflows/.campaign.md` in a PR. +3. Run `gh aw compile`. +4. Run the generated orchestrator workflow from the Actions tab. +5. Apply the tracker label to issues/PRs you want tracked. -## Lowest-friction walkthrough (recommended) +## 1) Create the dashboard (GitHub Project) -The simplest, least-permissions way to run an agentic campaign is: +In GitHub: your org → **Projects** → **New project**. -### 1. Create the agentic campaign spec (in a PR) +- Keep it simple: a Table view is enough. +- If you want lanes, create a Board view and group by a single-select field (commonly `Status`). -Choose one of these approaches: +Copy the Project URL (it must be a full URL). -**Option A (No-code)**: Use the "🚀 start an agentic campaign" issue form in the GitHub UI to capture intent with structured fields. The form creates an agentic campaign issue, and an agent can scaffold the spec file for you. +## 2) Create the campaign spec -**Option B (CLI)**: Use `gh aw campaign new ` to generate an agentic campaign spec file locally. +Create `.github/workflows/.campaign.md` with frontmatter like: -**Option C (Manual)**: Author `.github/workflows/.campaign.md` manually. +```yaml +id: framework-upgrade +version: "v1" +name: "Framework Upgrade" -### 2. Create the org Project board once (manual) +project-url: "https://github.com/orgs/ORG/projects/1" +tracker-label: "campaign:framework-upgrade" -Create an org Project v2 in the GitHub UI and copy its URL into `project-url`. +objective: "Upgrade all services to Framework vNext with zero downtime." +kpis: + - id: services_upgraded + name: "Services upgraded" + primary: true + direction: "increase" + target: 50 -This avoids requiring a PAT or GitHub App setup just to provision boards. +workflows: + - framework-upgrade +``` -Minimum clicks (one-time setup): -- In GitHub: your org → **Projects** → **New project**. -- Give it a name (for example: `Code Health: `). -- Choose any starting layout (Table/Board). You can change views later. -- Copy the Project URL and set it as `project-url` in the agentic campaign spec. +You can add governance and repo-memory wiring later; start with a working loop. -Optional but recommended for "kanban lanes": -- Create a **Board** view and set **Group by** to a single-select field (commonly `Status`). -- Note: workflows can create/update fields and single-select options, but they do not currently create or configure Project views. +## 3) Compile -### 3. Have workflows keep the board in sync using `GITHUB_TOKEN` +Run: -The generated orchestrator workflow automatically keeps the board in sync using the `update-project` safe output. +```bash +gh aw compile +``` -Default behavior is **update-only**: if the board does not exist, the project job fails with instructions. +This validates the spec. When the spec has meaningful details (tracker label, workflows, memory paths, or a metrics glob), `compile` also generates an orchestrator `.campaign.g.md` and compiles it to `.lock.yml`. -### 4. Opt in to auto-creating the board only when you intend to +## 4) Run the orchestrator -If you want workflows to create missing boards, explicitly set `create_if_missing: true` in the `update_project` output. +Trigger the orchestrator workflow from GitHub Actions. Its job is to keep the dashboard in sync: -For many orgs, you may also need a token override (`safe-outputs.update-project.github-token`) with sufficient org Project permissions. +- Finds tracker-labeled issues/PRs +- Adds them to the Project +- Updates fields/status +- Posts a short report -## Start an Agentic Campaign with GitHub Issue Forms +## 5) Add work items -For a low-code/no-code approach, you can create an agentic campaign using the GitHub UI with the "🚀 Start an Agentic Campaign" issue form: +Apply the tracker label (for example `campaign:framework-upgrade`) to issues/PRs you want tracked. The orchestrator will pick them up on the next run. -1. **Go to the repository's Issues tab** and click "New issue" -2. **Select "🚀 Start an Agentic Campaign"** from the available templates -3. **Fill in the structured form fields**: - - **Campaign Name** (required): Human-readable name (e.g., "Framework Upgrade Q1 2025") - - **Campaign Identifier** (required): Unique ID using lowercase letters, digits, and hyphens (e.g., "framework-upgrade-q1-2025") - - **Campaign Version** (required): Version string (defaults to "v1") - - **Project Board URL** (required): URL of the GitHub Project you created to serve as the agentic campaign dashboard - - **Campaign Type** (optional): Select from Migration, Upgrade/Modernization, Security Remediation, etc. - - **Scope** (optional): Define what repositories, components, or areas will be affected - - **Constraints** (optional): List any constraints or requirements (deadlines, approvals, etc.) - - **Prior Learnings** (optional): Share relevant learnings from past similar campaigns -4. **Submit the form** to create the agentic campaign issue +## Optional: repo-memory for durable state -### What happens after submission +If you enable repo-memory for campaigns, use a stable layout: + +- `memory/campaigns//cursor.json` +- `memory/campaigns//metrics/.json` + +Campaign tooling enforces that a campaign repo-memory write includes a cursor and at least one metrics snapshot. + +## Start an agentic campaign with GitHub Issue Forms + +This repo also includes a “🚀 Start an Agentic Campaign” issue form. Use it when you want to capture intent first and let an agent scaffold the spec in a PR. When you submit the issue form: diff --git a/docs/src/content/docs/guides/campaigns/specs.md b/docs/src/content/docs/guides/campaigns/specs.md index f053c18844..1359a38af3 100644 --- a/docs/src/content/docs/guides/campaigns/specs.md +++ b/docs/src/content/docs/guides/campaigns/specs.md @@ -3,9 +3,59 @@ title: "Campaign Specs" description: "Define and configure agentic campaigns with spec files, tracker labels, and recommended wiring" --- -Agentic campaigns are defined as Markdown files under `.github/workflows/` with a `.campaign.md` suffix. Each file has a YAML frontmatter block describing the agentic campaign. +Campaigns are defined as Markdown files under `.github/workflows/` with a `.campaign.md` suffix. The YAML frontmatter is the campaign “contract”; the body can contain optional narrative context. -## Agentic campaign spec files +## What a campaign is (in gh-aw) + +In GitHub Agentic Workflows, a campaign is not “a special kind of workflow.” The `.campaign.md` file is a specification: a reviewable contract that wires together agentic workflows around a shared initiative (a tracker label, a GitHub Project dashboard, and optional durable state). + +In a typical setup: + +- Worker workflows do the work. They run an agent and use safe-outputs (for example `create_pull_request`, `add_comment`, or `update_issues`) for write operations. +- A generated orchestrator workflow keeps the campaign coherent over time. It discovers items tagged with your tracker label, updates the Project board, and produces ongoing progress reporting. +- Repo-memory (optional) makes the campaign repeatable. It lets you store a cursor checkpoint and append-only metrics snapshots so each run can pick up where the last one left off. + +### Mental model (ASCII) + +``` + .github/workflows/.campaign.md + (specification / contract) + | + | gh aw compile + v + .github/workflows/.campaign.g.md -> .campaign.lock.yml + (generated orchestrator source) (compiled workflow) + | + | discovers items via tracker-label (e.g. campaign:) + | updates Project dashboard + | reads/writes repo-memory (cursor + metrics) + v + +---------------------------+ + | Orchestrator workflow | + +---------------------------+ + | | + | triggers/coordinates | + v v + +----------------+ +----------------+ + | Worker workflow | | Worker workflow | + | (agent + | | (agent + | + | safe-outputs) | | safe-outputs) | + +----------------+ +----------------+ + | + | creates/updates Issues/PRs with tracker-label + v + GitHub Project board <--- "campaign dashboard" + + repo-memory branch: + memory/campaigns//cursor.json + memory/campaigns//metrics/.json +``` + +Editable diagram (draw.io): `docs/src/content/docs/guides/campaigns/agentic-campaign.drawio` + +This is why campaigns feel like “delegation over time”: you are defining success, scope, and reporting, not just describing a single run. + +## Minimal spec ```yaml # .github/workflows/framework-upgrade.campaign.md @@ -15,71 +65,81 @@ name: "Framework Upgrade" description: "Move services to Framework vNext" project-url: "https://github.com/orgs/ORG/projects/1" +tracker-label: "campaign:framework-upgrade" + +objective: "Upgrade all services to Framework vNext with zero downtime." +kpis: + - id: services_upgraded + name: "Services upgraded" + primary: true + direction: "increase" + target: 50 + - id: incidents + name: "Incidents caused" + direction: "decrease" + target: 0 workflows: - framework-upgrade -tracker-label: "campaign:framework-upgrade" state: "active" owners: - "platform-team" ``` -Common fields you'll reach for as the initiative grows: +## Core fields (what they do) -- `project-url`: the GitHub Project URL used as the primary campaign dashboard -- `tracker-label`: the label that ties issues/PRs back to the agentic campaign -- `memory-paths` / `metrics-glob`: where baselines and metrics snapshots live on your repo-memory branch -- `approval-policy`: the expectations for human approval (required approvals/roles) -- `governance`: pacing and opt-out policies for orchestrator operations (see below) +- `id`: stable identifier used for file naming, reporting, and (if used) repo-memory paths. +- `project-url`: the GitHub Project that acts as the campaign dashboard. +- `tracker-label`: the label applied to issues and pull requests that belong to the campaign (commonly `campaign:`). This is the key that lets the orchestrator discover work across runs. +- `objective`: a single sentence describing what “done” means. +- `kpis`: the measures you use to report progress (exactly one should be marked `primary`). +- `workflows`: the participating workflow IDs. These refer to workflows in the repo (commonly `.github/workflows/.md`), and they can be scheduled, event-driven, or long-running. -Once you have a spec, the remaining question is consistency: what should every agentic campaign produce so people can follow along? +## KPIs (recommended shape) -## Governance policies +Keep KPIs small and crisp: -The `governance` section provides lightweight controls for how the orchestrator manages campaign tracking: +- Use 1 primary KPI + a few supporting KPIs. +- Use `direction: increase|decrease|maintain` to describe the desired trend. +- Use `target` when there is a clear threshold. -```yaml -governance: - max-new-items-per-run: 10 # Limit new items added to Project per run - max-discovery-items-per-run: 100 # Limit items scanned during discovery - max-discovery-pages-per-run: 5 # Limit API result pages fetched - opt-out-labels: ["campaign:skip"] # Labels that exclude items from tracking - do-not-downgrade-done-items: true # Prevent Done → In Progress transitions - max-project-updates-per-run: 50 # Limit Project update operations - max-comments-per-run: 10 # Limit comment operations -``` +If you define `kpis`, also define `objective` (and vice versa). It keeps the spec reviewable and makes reports consistent. + +## Durable state (repo-memory) + +If you use repo-memory for campaigns, standardize on one layout so runs are comparable: -**Common use cases**: -- **Pacing**: Use `max-new-items-per-run` to gradually roll out tracking (e.g., 10 items per day) -- **Rate limiting**: Use `max-project-updates-per-run` to avoid GitHub API throttling -- **Opt-out**: Use `opt-out-labels` to let teams mark items as out-of-scope -- **Stability**: Use `do-not-downgrade-done-items` to prevent reopened items from disrupting reports +- `memory/campaigns//cursor.json` +- `memory/campaigns//metrics/.json` -## Recommended default wiring +Typical wiring in the spec: -To keep agentic campaigns consistent and easy to read, most teams use a predictable set of primitives: +```yaml +memory-paths: + - "memory/campaigns/framework-upgrade/cursor.json" +metrics-glob: "memory/campaigns/framework-upgrade/metrics/*.json" +``` + +Campaign tooling enforces the durability contract at push time: a campaign repo-memory write must include a cursor and at least one metrics snapshot. -- **Tracker label** (for example, `campaign:`) applied to every issue/PR in the agentic campaign. -- **Epic issue** (often also labeled `campaign-tracker`) as the human-readable command center. -- **GitHub Project** as the dashboard (primary campaign dashboard). -- **Repo-memory metrics** (daily JSON snapshots) to compute velocity/ETAs and enable trend reporting. -- **Tracker IDs in worker workflows** (e.g., `tracker-id: "worker-name"`) to enable orchestrator discovery of worker-created assets. -- **Generated orchestrator** to keep the Project in sync and post periodic updates. -- **Custom date fields** (optional, for roadmap views) like `Start Date` and `End Date` to visualize campaign timeline. +## Governance (pacing & safety) -If you want to try this end-to-end quickly, start with the [Getting Started guide](/gh-aw/guides/campaigns/getting-started/). +Use `governance` to keep orchestration predictable and reviewable: -## Spec validation and compilation +```yaml +governance: + max-new-items-per-run: 10 + max-discovery-items-per-run: 100 + max-discovery-pages-per-run: 5 + opt-out-labels: ["campaign:skip"] + do-not-downgrade-done-items: true + max-project-updates-per-run: 50 + max-comments-per-run: 10 +``` -When the spec has meaningful details (tracker label, workflows, memory paths, or a metrics glob), `gh aw compile` will also generate an orchestrator workflow named `.github/workflows/.campaign.g.md` and compile it to a corresponding `.lock.yml`. +## Compilation and orchestrators -The generated orchestrator: -- Discovers tracker-labeled issues and PRs matching the campaign -- Discovers worker-created assets (if workers are configured with tracker-ids) -- Adds new items to the GitHub Project board -- Updates status fields as work progresses -- Enforces governance rules (e.g., max items per run, no downgrade of completed items) -- Posts periodic status reports +`gh aw compile` validates campaign specs. When the spec has meaningful details (tracker label, workflows, memory paths, or a metrics glob), it also generates an orchestrator `.github/workflows/.campaign.g.md` and compiles it to `.lock.yml`. -See [Agentic campaign specs and orchestrators](/gh-aw/setup/cli/#compile) for details. +See [Agentic campaign specs and orchestrators](/gh-aw/setup/cli/#compile). diff --git a/pkg/campaign/loader.go b/pkg/campaign/loader.go index 906d43dafc..962c7de990 100644 --- a/pkg/campaign/loader.go +++ b/pkg/campaign/loader.go @@ -155,6 +155,8 @@ func CreateSpecSkeleton(rootDir, id string, force bool) (string, error) { Version: "v1", State: "planned", TrackerLabel: "campaign:" + id, + MemoryPaths: []string{"memory/campaigns/" + id + "/**"}, + MetricsGlob: "memory/campaigns/" + id + "/metrics/*.json", CursorGlob: "memory/campaigns/" + id + "/cursor.json", Governance: &CampaignGovernancePolicy{ MaxNewItemsPerRun: 25, diff --git a/pkg/campaign/orchestrator.go b/pkg/campaign/orchestrator.go index a86d348559..d4d1970f6f 100644 --- a/pkg/campaign/orchestrator.go +++ b/pkg/campaign/orchestrator.go @@ -54,6 +54,32 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W fmt.Fprintf(markdownBuilder, "- Tracker label: `%s`\n", spec.TrackerLabel) hasDetails = true } + if strings.TrimSpace(spec.Objective) != "" { + fmt.Fprintf(markdownBuilder, "- Objective: %s\n", strings.TrimSpace(spec.Objective)) + hasDetails = true + } + if len(spec.KPIs) > 0 { + markdownBuilder.WriteString("- KPIs:\n") + for _, kpi := range spec.KPIs { + name := strings.TrimSpace(kpi.Name) + if name == "" { + name = "(unnamed)" + } + priority := strings.TrimSpace(kpi.Priority) + if priority == "" && len(spec.KPIs) == 1 { + priority = "primary" + } + unit := strings.TrimSpace(kpi.Unit) + if unit != "" { + unit = " " + unit + } + if priority != "" { + priority = " (" + priority + ")" + } + fmt.Fprintf(markdownBuilder, " - %s%s: baseline %.4g → target %.4g over %d days%s\n", name, priority, kpi.Baseline, kpi.Target, kpi.TimeWindowDays, unit) + } + hasDetails = true + } if len(spec.Workflows) > 0 { markdownBuilder.WriteString("- Associated workflows: ") markdownBuilder.WriteString(strings.Join(spec.Workflows, ", ")) @@ -123,8 +149,13 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W // Render orchestrator instructions using templates // All orchestrators follow the same system-agnostic rules with no conditional logic promptData := CampaignPromptData{ProjectURL: strings.TrimSpace(spec.ProjectURL)} + promptData.Objective = strings.TrimSpace(spec.Objective) + if len(spec.KPIs) > 0 { + promptData.KPIs = spec.KPIs + } promptData.TrackerLabel = strings.TrimSpace(spec.TrackerLabel) promptData.CursorGlob = strings.TrimSpace(spec.CursorGlob) + promptData.MetricsGlob = strings.TrimSpace(spec.MetricsGlob) if spec.Governance != nil { promptData.MaxDiscoveryItemsPerRun = spec.Governance.MaxDiscoveryItemsPerRun promptData.MaxDiscoveryPagesPerRun = spec.Governance.MaxDiscoveryPagesPerRun @@ -181,7 +212,19 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W RunsOn: "runs-on: ubuntu-latest", // Default roles match the workflow compiler's defaults so that // membership checks have a non-empty GH_AW_REQUIRED_ROLES value. - Roles: []string{"admin", "maintainer", "write"}, + Roles: []string{"admin", "maintainer", "write"}, + Tools: map[string]any{ + "github": map[string]any{ + "toolsets": []any{"default", "actions", "code_security"}, + }, + "repo-memory": []any{ + map[string]any{ + "id": "campaigns", + "branch-name": "memory/campaigns", + "file-glob": []any{fmt.Sprintf("%s/**", spec.ID)}, + }, + }, + }, SafeOutputs: safeOutputs, } diff --git a/pkg/campaign/orchestrator_test.go b/pkg/campaign/orchestrator_test.go index cf75bb968d..1511ad99af 100644 --- a/pkg/campaign/orchestrator_test.go +++ b/pkg/campaign/orchestrator_test.go @@ -12,8 +12,8 @@ func TestBuildOrchestrator_BasicShape(t *testing.T) { Description: "Reduce oversized non-test Go files under pkg/ to ≤800 LOC via tracked refactors.", ProjectURL: "https://github.com/orgs/githubnext/projects/64", Workflows: []string{"daily-file-diet"}, - MemoryPaths: []string{"memory/campaigns/go-file-size-reduction-project64-*/**"}, - MetricsGlob: "memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json", + MemoryPaths: []string{"memory/campaigns/go-file-size-reduction-project64/**"}, + MetricsGlob: "memory/campaigns/go-file-size-reduction-project64/metrics/*.json", TrackerLabel: "campaign:go-file-size-reduction-project64", } @@ -99,6 +99,49 @@ func TestBuildOrchestrator_CompletionInstructions(t *testing.T) { } } +func TestBuildOrchestrator_ObjectiveAndKPIsAreRendered(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + Description: "A test campaign", + ProjectURL: "https://github.com/orgs/test/projects/1", + Workflows: []string{"daily-file-diet"}, + TrackerLabel: "campaign:test", + Objective: "Improve CI stability", + KPIs: []CampaignKPI{ + { + Name: "Build success rate", + Priority: "primary", + Unit: "ratio", + Baseline: 0.8, + Target: 0.95, + TimeWindowDays: 7, + Direction: "increase", + Source: "ci", + }, + }, + } + + mdPath := ".github/workflows/test-campaign.campaign.md" + data, _ := BuildOrchestrator(spec, mdPath) + if data == nil { + t.Fatalf("expected non-nil WorkflowData") + } + + // Golden assertions: these should only change if we intentionally change the orchestrator contract. + expectedPhrases := []string{ + "### Objective and KPIs (first-class)", + "Objective: Improve CI stability", + "Build success rate", + "Deterministic planner step", + } + for _, expected := range expectedPhrases { + if !strings.Contains(data.MarkdownContent, expected) { + t.Errorf("expected markdown to contain %q, got: %q", expected, data.MarkdownContent) + } + } +} + func TestBuildOrchestrator_TrackerIDMonitoring(t *testing.T) { spec := &CampaignSpec{ ID: "test-campaign", diff --git a/pkg/campaign/prompts/orchestrator_instructions.md b/pkg/campaign/prompts/orchestrator_instructions.md index 868afca59f..5c179d8610 100644 --- a/pkg/campaign/prompts/orchestrator_instructions.md +++ b/pkg/campaign/prompts/orchestrator_instructions.md @@ -12,6 +12,22 @@ This orchestrator follows system-agnostic rules that enforce clean separation be {{ if .CursorGlob }} **Cursor file (repo-memory)**: `{{ .CursorGlob }}` + +You must treat this file as the source of truth for incremental discovery: +- If it exists, read it first and continue from that boundary. +- If it does not exist yet, create it by the end of the run. +- Always write the updated cursor back to the same path. +{{ end }} + +{{ if .MetricsGlob }} +**Metrics/KPI snapshots (repo-memory)**: `{{ .MetricsGlob }}` + +You must persist a per-run metrics snapshot (including KPI values and trends) as a JSON file stored in the metrics directory implied by the glob above. + +Guidance: +- Use an ISO date (UTC) filename, for example: `metrics/2025-12-22.json`. +- Keep snapshots append-only: write a new file per run; do not rewrite historical snapshots. +- If a KPI is present, record its computed value and trend (Improving/Flat/Regressing). {{ end }} {{ if gt .MaxDiscoveryItemsPerRun 0 }} **Read budget**: max discovery items per run: {{ .MaxDiscoveryItemsPerRun }} @@ -36,6 +52,33 @@ This orchestrator follows system-agnostic rules that enforce clean separation be 12. **Idempotent operations** - Re-execution produces the same result without corruption 13. **Dashboard synchronization** - Keep Project items in sync with tracker-labeled issues/PRs +### Objective and KPIs (first-class) + +{{ if .Objective }} +**Objective**: {{ .Objective }} +{{ end }} + +{{ if .KPIs }} +**KPIs** (max 3): +{{ range .KPIs }} +- {{ .Name }}{{ if .Priority }} ({{ .Priority }}){{ end }}: baseline {{ .Baseline }} → target {{ .Target }} over {{ .TimeWindowDays }} days{{ if .Unit }} (unit: {{ .Unit }}){{ end }}{{ if .Direction }} (direction: {{ .Direction }}){{ end }}{{ if .Source }} (source: {{ .Source }}){{ end }} +{{ end }} +{{ end }} + +If objective/KPIs are present, you must: +- Compute a per-run KPI snapshot (as-of now) using GitHub signals. +- Determine trend status for each KPI: Improving / Flat / Regressing (use the KPI direction when present). +- Tie all decisions to the primary KPI first. + +### Default signals (built-in) + +Collect these signals every run (bounded by the read budgets above): +- **CI health**: recent check/workflow outcomes relevant to the repo(s) in scope. +- **PR cycle time**: recent PR open→merge latency and backlog size. +- **Security alerts**: open code scanning / Dependabot / secret scanning items (as available). + +If a signal cannot be retrieved (permissions/tooling), explicitly report it as unavailable and proceed with the remaining signals. + ### Orchestration Workflow Execute these steps in sequence each time this orchestrator runs: @@ -65,6 +108,25 @@ Execute these steps in sequence each time this orchestrator runs: #### Phase 2: Make Decisions (Planning) +4.5 **Deterministic planner step (required when objective/KPIs are present)** + +Before choosing additions/updates, produce a small, bounded plan that is rule-based and reproducible from the discovered state: +- Output at most **3** planned actions. +- Prefer actions that are directly connected to improving the **primary** KPI. +- If signals indicate risk or uncertainty, prefer smaller, reversible actions. + +Plan format (keep under 2KB): +```json +{ + "objective": "...", + "primary_kpi": "...", + "kpi_trends": [{"name": "...", "trend": "Improving|Flat|Regressing"}], + "actions": [ + {"type": "add_to_project|update_status|comment", "why": "...", "target_url": "..."} + ] +} +``` + 5. **Decide additions (with pacing)** - For each new item discovered: - Decision: Add to board? (Default: yes for all items with tracker label or worker tracker-id) - If `governance.max-new-items-per-run` is set, add at most that many new items diff --git a/pkg/campaign/schemas/campaign_spec_schema.json b/pkg/campaign/schemas/campaign_spec_schema.json index 89e6bcd365..1064a083aa 100644 --- a/pkg/campaign/schemas/campaign_spec_schema.json +++ b/pkg/campaign/schemas/campaign_spec_schema.json @@ -20,6 +20,68 @@ "type": "string", "description": "Brief description of the campaign" }, + "objective": { + "type": "string", + "description": "Outcome-owned objective statement for the campaign (what success means)", + "minLength": 1 + }, + "kpis": { + "type": "array", + "description": "Up to 3 KPIs used to measure the campaign objective", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Optional stable identifier for the KPI (lowercase letters, digits, and hyphens)", + "pattern": "^[a-z0-9-]+$", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "Human-readable KPI name", + "minLength": 1 + }, + "priority": { + "type": "string", + "description": "KPI priority: primary (exactly one per campaign) or supporting", + "enum": ["primary", "supporting"] + }, + "unit": { + "type": "string", + "description": "Optional KPI unit (e.g., percent, days, count)", + "minLength": 1 + }, + "baseline": { + "type": "number", + "description": "Baseline KPI value" + }, + "target": { + "type": "number", + "description": "Target KPI value" + }, + "time-window-days": { + "type": "integer", + "description": "Rolling time window, in days, used for KPI measurement (e.g., 7, 14, 30)", + "minimum": 1 + }, + "direction": { + "type": "string", + "description": "Whether improvement means increasing or decreasing the KPI", + "enum": ["increase", "decrease"] + }, + "source": { + "type": "string", + "description": "Signal source used to compute this KPI", + "enum": ["ci", "pull_requests", "code_security", "custom"] + } + }, + "required": ["name", "baseline", "target", "time-window-days"] + } + }, "project-url": { "type": "string", "description": "URL of the GitHub Project used as the primary campaign dashboard", diff --git a/pkg/campaign/spec.go b/pkg/campaign/spec.go index bc85de7c6e..2372b0a9fe 100644 --- a/pkg/campaign/spec.go +++ b/pkg/campaign/spec.go @@ -14,6 +14,14 @@ type CampaignSpec struct { Name string `yaml:"name" json:"name" console:"header:Name,maxlen:30"` Description string `yaml:"description,omitempty" json:"description,omitempty" console:"header:Description,omitempty,maxlen:60"` + // Objective is an optional outcome-owned statement describing what success means + // for this campaign. + Objective string `yaml:"objective,omitempty" json:"objective,omitempty" console:"header:Objective,omitempty,maxlen:60"` + + // KPIs is an optional list of KPIs used to measure progress toward the objective. + // Recommended: 1 primary KPI plus up to 2 supporting KPIs. + KPIs []CampaignKPI `yaml:"kpis,omitempty" json:"kpis,omitempty"` + // ProjectURL points to the GitHub Project used as the primary campaign // dashboard. ProjectURL string `yaml:"project-url,omitempty" json:"project_url,omitempty" console:"header:Project URL,omitempty,maxlen:40"` @@ -27,7 +35,7 @@ type CampaignSpec struct { Workflows []string `yaml:"workflows,omitempty" json:"workflows,omitempty" console:"header:Workflows,omitempty,maxlen:40"` // MemoryPaths documents where this campaign writes its repo-memory - // (for example: memory/campaigns/incident-*/**). + // (for example: memory/campaigns/incident-response/**). MemoryPaths []string `yaml:"memory-paths,omitempty" json:"memory_paths,omitempty" console:"header:Memory Paths,omitempty,maxlen:40"` // MetricsGlob is an optional glob (relative to the repository root) @@ -92,6 +100,39 @@ type CampaignSpec struct { ConfigPath string `yaml:"-" json:"config_path" console:"header:Config Path,maxlen:60"` } +// CampaignKPI defines a single KPI used for campaign measurement. +type CampaignKPI struct { + // ID is an optional stable identifier for this KPI. + ID string `yaml:"id,omitempty" json:"id,omitempty"` + + // Name is a human-readable KPI name. + Name string `yaml:"name" json:"name"` + + // Priority indicates whether this KPI is the primary KPI or a supporting KPI. + // Expected values: primary, supporting. + Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` + + // Unit is an optional unit string (e.g., percent, days, count). + Unit string `yaml:"unit,omitempty" json:"unit,omitempty"` + + // Baseline is the baseline KPI value. + Baseline float64 `yaml:"baseline" json:"baseline"` + + // Target is the target KPI value. + Target float64 `yaml:"target" json:"target"` + + // TimeWindowDays is the rolling time window (in days) used to compute the KPI. + TimeWindowDays int `yaml:"time-window-days" json:"time-window-days"` + + // Direction indicates whether improvement means increasing or decreasing. + // Expected values: increase, decrease. + Direction string `yaml:"direction,omitempty" json:"direction,omitempty"` + + // Source describes the signal source used to compute the KPI. + // Expected values: ci, pull_requests, code_security, custom. + Source string `yaml:"source,omitempty" json:"source,omitempty"` +} + // CampaignGovernancePolicy captures lightweight pacing and opt-out policies. // This is intentionally scoped to what gh-aw can apply safely and consistently // via prompts and safe-output job limits. diff --git a/pkg/campaign/template.go b/pkg/campaign/template.go index f9695e04e1..5412aa9466 100644 --- a/pkg/campaign/template.go +++ b/pkg/campaign/template.go @@ -22,6 +22,12 @@ var closingInstructionsTemplate string // CampaignPromptData holds data for rendering campaign orchestrator prompts. type CampaignPromptData struct { + // Objective is the campaign objective statement. + Objective string + + // KPIs is the KPI definition list for this campaign. + KPIs []CampaignKPI + // ProjectURL is the GitHub Project URL ProjectURL string @@ -31,6 +37,9 @@ type CampaignPromptData struct { // CursorGlob is a glob for locating the durable cursor/checkpoint file in repo-memory. CursorGlob string + // MetricsGlob is a glob for locating the metrics snapshot directory in repo-memory. + MetricsGlob string + // MaxDiscoveryItemsPerRun caps how many candidate items may be scanned during discovery. MaxDiscoveryItemsPerRun int diff --git a/pkg/campaign/validation.go b/pkg/campaign/validation.go index f28d94c295..9a14a2093b 100644 --- a/pkg/campaign/validation.go +++ b/pkg/campaign/validation.go @@ -116,6 +116,9 @@ func ValidateSpec(spec *CampaignSpec) []string { } } + // Goals/KPIs: optional, but when provided they must be consistent and well-formed. + problems = append(problems, validateObjectiveAndKPIs(spec)...) + if len(problems) == 0 { validationLog.Printf("Campaign spec '%s' validation passed with no problems", spec.ID) } else { @@ -125,6 +128,67 @@ func ValidateSpec(spec *CampaignSpec) []string { return problems } +func validateObjectiveAndKPIs(spec *CampaignSpec) []string { + var problems []string + + objective := strings.TrimSpace(spec.Objective) + if objective == "" && len(spec.KPIs) > 0 { + problems = append(problems, "objective should be set when kpis are provided") + } + if objective != "" && len(spec.KPIs) == 0 { + problems = append(problems, "kpis should include at least one KPI when objective is provided") + } + if len(spec.KPIs) == 0 { + return problems + } + + primaryCount := 0 + for _, kpi := range spec.KPIs { + name := strings.TrimSpace(kpi.Name) + if name == "" { + name = "(unnamed)" + } + if strings.TrimSpace(kpi.Priority) == "primary" { + primaryCount++ + } + if kpi.TimeWindowDays < 1 { + problems = append(problems, fmt.Sprintf("kpi '%s': time-window-days must be >= 1", name)) + } + if dir := strings.TrimSpace(kpi.Direction); dir != "" { + switch dir { + case "increase", "decrease": + // ok + default: + problems = append(problems, fmt.Sprintf("kpi '%s': direction must be one of: increase, decrease", name)) + } + } + if src := strings.TrimSpace(kpi.Source); src != "" { + switch src { + case "ci", "pull_requests", "code_security", "custom": + // ok + default: + problems = append(problems, fmt.Sprintf("kpi '%s': source must be one of: ci, pull_requests, code_security, custom", name)) + } + } + } + + // Semantic rule: exactly one primary KPI when there are multiple KPIs. + // If there is only one KPI and priority is omitted, treat it as implicitly primary. + if len(spec.KPIs) == 1 { + if strings.TrimSpace(spec.KPIs[0].Priority) == "" { + return problems + } + } + if primaryCount == 0 { + problems = append(problems, "kpis must include exactly one primary KPI (priority: primary)") + } + if primaryCount > 1 { + problems = append(problems, "kpis must include exactly one primary KPI (multiple primary KPIs found)") + } + + return problems +} + // getCompiledSchema returns the compiled campaign spec schema, compiling it once and caching func getCompiledSchema() (*jsonschema.Schema, error) { compiledSchemaOnce.Do(func() { @@ -187,10 +251,24 @@ func ValidateSpecWithSchema(spec *CampaignSpec) []string { MaxCommentsPerRun int `json:"max-comments-per-run,omitempty"` } + type CampaignKPIForValidation struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Priority string `json:"priority,omitempty"` + Unit string `json:"unit,omitempty"` + Baseline float64 `json:"baseline"` + Target float64 `json:"target"` + TimeWindowDays int `json:"time-window-days"` + Direction string `json:"direction,omitempty"` + Source string `json:"source,omitempty"` + } + type CampaignSpecForValidation struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` + Objective string `json:"objective,omitempty"` + KPIs []CampaignKPIForValidation `json:"kpis,omitempty"` ProjectURL string `json:"project-url,omitempty"` ProjectGitHubToken string `json:"project-github-token,omitempty"` Version string `json:"version,omitempty"` @@ -210,9 +288,30 @@ func ValidateSpecWithSchema(spec *CampaignSpec) []string { } validationSpec := CampaignSpecForValidation{ - ID: spec.ID, - Name: spec.Name, - Description: spec.Description, + ID: spec.ID, + Name: spec.Name, + Description: spec.Description, + Objective: strings.TrimSpace(spec.Objective), + KPIs: func() []CampaignKPIForValidation { + if len(spec.KPIs) == 0 { + return nil + } + out := make([]CampaignKPIForValidation, 0, len(spec.KPIs)) + for _, kpi := range spec.KPIs { + out = append(out, CampaignKPIForValidation{ + ID: strings.TrimSpace(kpi.ID), + Name: strings.TrimSpace(kpi.Name), + Priority: strings.TrimSpace(kpi.Priority), + Unit: strings.TrimSpace(kpi.Unit), + Baseline: kpi.Baseline, + Target: kpi.Target, + TimeWindowDays: kpi.TimeWindowDays, + Direction: strings.TrimSpace(kpi.Direction), + Source: strings.TrimSpace(kpi.Source), + }) + } + return out + }(), ProjectURL: spec.ProjectURL, ProjectGitHubToken: spec.ProjectGitHubToken, Version: spec.Version, diff --git a/pkg/campaign/validation_test.go b/pkg/campaign/validation_test.go index 2575c2e75d..dbd5b542db 100644 --- a/pkg/campaign/validation_test.go +++ b/pkg/campaign/validation_test.go @@ -308,3 +308,199 @@ func TestValidateSpec_CompleteSpec(t *testing.T) { t.Errorf("Expected no validation problems for complete spec, got: %v", problems) } } + +func TestValidateSpec_ObjectiveWithoutKPIs(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + Objective: "Improve CI stability", + // KPIs intentionally omitted + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for objective without kpis") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "kpis should include at least one KPI") { + found = true + break + } + } + if !found { + t.Errorf("Expected objective/kpis coupling validation problem, got: %v", problems) + } +} + +func TestValidateSpec_KPIsWithoutObjective(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + KPIs: []CampaignKPI{ + { + Name: "Build success rate", + Priority: "primary", + Baseline: 0.8, + Target: 0.95, + TimeWindowDays: 7, + }, + }, + // Objective intentionally omitted + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for kpis without objective") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "objective should be set when kpis") { + found = true + break + } + } + if !found { + t.Errorf("Expected objective/kpis coupling validation problem, got: %v", problems) + } +} + +func TestValidateSpec_KPIsMultipleWithoutPrimary(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + Objective: "Improve delivery", + KPIs: []CampaignKPI{ + {Name: "PR cycle time", Priority: "supporting", Baseline: 10, Target: 7, TimeWindowDays: 30}, + {Name: "Open PRs", Priority: "supporting", Baseline: 20, Target: 10, TimeWindowDays: 30}, + }, + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for multiple KPIs without a primary") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "exactly one primary KPI") && strings.Contains(p, "priority: primary") { + found = true + break + } + } + if !found { + t.Errorf("Expected primary KPI validation problem, got: %v", problems) + } +} + +func TestValidateSpec_KPIsMultipleWithMultiplePrimary(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + Objective: "Improve delivery", + KPIs: []CampaignKPI{ + {Name: "Build success rate", Priority: "primary", Baseline: 0.8, Target: 0.95, TimeWindowDays: 7}, + {Name: "PR cycle time", Priority: "primary", Baseline: 10, Target: 7, TimeWindowDays: 30}, + }, + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for multiple primary KPIs") + } + + found := false + for _, p := range problems { + if strings.Contains(p, "multiple primary KPIs found") { + found = true + break + } + } + if !found { + t.Errorf("Expected multiple primary KPI validation problem, got: %v", problems) + } +} + +func TestValidateSpec_SingleKPIOmitsPriorityIsAllowed(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + Objective: "Improve CI stability", + KPIs: []CampaignKPI{ + { + Name: "Build success rate", + // Priority intentionally omitted; should be implicitly primary. + Baseline: 0.8, + Target: 0.95, + TimeWindowDays: 7, + }, + }, + } + + problems := ValidateSpec(spec) + if len(problems) != 0 { + t.Errorf("Expected no validation problems for single KPI with omitted priority, got: %v", problems) + } +} + +func TestValidateSpec_KPIFieldConstraints(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Workflows: []string{"workflow1"}, + TrackerLabel: "campaign:test", + Objective: "Improve CI stability", + KPIs: []CampaignKPI{ + { + Name: "Build success rate", + Priority: "primary", + Baseline: 0.8, + Target: 0.95, + TimeWindowDays: 0, + Direction: "up", + Source: "unknown", + }, + }, + } + + problems := ValidateSpec(spec) + if len(problems) == 0 { + t.Fatal("Expected validation problems for invalid KPI fields") + } + + expectSubstrings := []string{ + "time-window-days must be >= 1", + "direction must be one of: increase, decrease", + "source must be one of: ci, pull_requests, code_security, custom", + } + for _, needle := range expectSubstrings { + found := false + for _, p := range problems { + if strings.Contains(p, needle) { + found = true + break + } + } + if !found { + t.Errorf("Expected validation problem containing %q, got: %v", needle, problems) + } + } +} diff --git a/pkg/cli/compile_campaign_orchestrator_test.go b/pkg/cli/compile_campaign_orchestrator_test.go index ecff7b1f53..c480593eaa 100644 --- a/pkg/cli/compile_campaign_orchestrator_test.go +++ b/pkg/cli/compile_campaign_orchestrator_test.go @@ -21,7 +21,7 @@ func TestGenerateAndCompileCampaignOrchestrator(t *testing.T) { Description: "A test campaign", Workflows: []string{"example-workflow"}, TrackerLabel: "campaign:test-campaign", - MemoryPaths: []string{"memory/campaigns/test-campaign-*/**"}, + MemoryPaths: []string{"memory/campaigns/test-campaign/**"}, } compiler := workflow.NewCompiler(false, "", GetVersion()) @@ -131,7 +131,7 @@ func TestCampaignSourceCommentStability(t *testing.T) { Description: "A test campaign for path stability", Workflows: []string{"example-workflow"}, TrackerLabel: "campaign:test-campaign", - MemoryPaths: []string{"memory/campaigns/test-campaign-*/**"}, + MemoryPaths: []string{"memory/campaigns/test-campaign/**"}, } compiler := workflow.NewCompiler(false, "", GetVersion()) @@ -250,7 +250,7 @@ func TestCampaignOrchestratorGitHubToken(t *testing.T) { Description: "A test campaign with custom GitHub token", Workflows: []string{"example-workflow"}, TrackerLabel: "campaign:test-campaign-with-token", - MemoryPaths: []string{"memory/campaigns/test-campaign-with-token-*/**"}, + MemoryPaths: []string{"memory/campaigns/test-campaign-with-token/**"}, ProjectGitHubToken: "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}", } @@ -303,7 +303,7 @@ func TestCampaignOrchestratorGitHubToken(t *testing.T) { Description: "A test campaign without custom GitHub token", Workflows: []string{"example-workflow"}, TrackerLabel: "campaign:test-campaign-no-token", - MemoryPaths: []string{"memory/campaigns/test-campaign-no-token-*/**"}, + MemoryPaths: []string{"memory/campaigns/test-campaign-no-token/**"}, // ProjectGitHubToken is intentionally omitted } diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 651c4912cc..e5137778f0 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -136,6 +136,9 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath } // Emit experimental warning for campaigns feature + // Campaign workflows (.campaign.md) are compiled by the campaign system in pkg/campaign/ + // This warning is part of the general workflow compilation pipeline and simply + // detects campaign files to inform users about the experimental status. if strings.HasSuffix(markdownPath, ".campaign.md") { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: campaigns - This is a preview feature for multi-workflow orchestration. The campaign spec format, CLI commands, and repo-memory conventions may change in future releases. Workflows may break or require migration when the feature stabilizes.")) c.IncrementWarningCount() diff --git a/pkg/workflow/permissions_shortcut_included_test.go b/pkg/workflow/permissions_shortcut_included_test.go index 85cc8a2d71..9fee57fba6 100644 --- a/pkg/workflow/permissions_shortcut_included_test.go +++ b/pkg/workflow/permissions_shortcut_included_test.go @@ -13,11 +13,11 @@ import ( // work correctly in included files, matching the UX of main workflows. func TestPermissionsShortcutInIncludedFiles(t *testing.T) { tests := []struct { - name string - includedPermissions string - mainPermissions string - expectCompilationError bool - expectLockFileContains string + name string + includedPermissions string + mainPermissions string + expectCompilationError bool + expectLockFileContains string }{ { name: "read-all shortcut in included file", @@ -130,11 +130,11 @@ tools: // TestPermissionsShortcutMixedUsage tests that shortcuts and object form can be mixed across files func TestPermissionsShortcutMixedUsage(t *testing.T) { tests := []struct { - name string - includedPermissions string - mainPermissions string - expectCompilationError bool - expectLockFileContains []string + name string + includedPermissions string + mainPermissions string + expectCompilationError bool + expectLockFileContains []string }{ { name: "shortcut in included file, object in main", diff --git a/pkg/workflow/safe_output_validation_config.go b/pkg/workflow/safe_output_validation_config.go index 65bfb7d62c..58727acfcb 100644 --- a/pkg/workflow/safe_output_validation_config.go +++ b/pkg/workflow/safe_output_validation_config.go @@ -232,7 +232,10 @@ var ValidationConfig = map[string]TypeValidationConfig{ "update_project": { DefaultMax: 10, Fields: map[string]FieldValidation{ - "project": {Required: true, Type: "string", Sanitize: true, MaxLength: 512, Pattern: "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", PatternError: "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)"}, + "project": {Required: true, Type: "string", Sanitize: true, MaxLength: 512, Pattern: "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", PatternError: "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)"}, + // campaign_id is an optional field used by Campaign Workflows to tag project items. + // When provided, the update-project safe output applies a "campaign:" label. + // This is part of the campaign tracking convention but not required for general use. "campaign_id": {Type: "string", Sanitize: true, MaxLength: 128}, "content_type": {Type: "string", Enum: []string{"issue", "pull_request"}}, "content_number": {OptionalPositiveInteger: true}, diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go index 545cb60179..146836f0f8 100644 --- a/pkg/workflow/tools.go +++ b/pkg/workflow/tools.go @@ -151,10 +151,25 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) { data.ParsedTools = NewTools(data.Tools) if data.Permissions == "" { - // Default behavior: keep existing workflows stable with read-all. - // Campaign orchestrators intentionally omit permissions from the generated - // .campaign.g.md frontmatter, so we compute explicit, minimal read permissions - // for the agent job at compile time. + // ============================================================================ + // PERMISSIONS DEFAULTS + // ============================================================================ + // Default behavior: keep existing workflows stable with read-all permissions. + // + // CAMPAIGN-SPECIFIC HANDLING: + // Campaign orchestrator workflows (.campaign.g.md files) are auto-generated + // by the BuildOrchestrator function in pkg/campaign/orchestrator.go. + // These generated workflows intentionally omit explicit permissions in their + // frontmatter, so we compute minimal read permissions here at compile time. + // + // This is part of the campaign orchestrator generation pattern where: + // 1. Campaign specs (.campaign.md) define high-level campaign configuration + // 2. BuildOrchestrator generates orchestrator workflows (.campaign.g.md) + // 3. The compiler applies default permissions to the generated workflow + // + // This separation allows campaign configuration to remain declarative while + // ensuring generated orchestrators have appropriate permissions. + // ============================================================================ if strings.HasSuffix(markdownPath, ".campaign.g.md") { perms := NewPermissions() // Campaign orchestrators always need to read repository contents and tracker issues.