# Completion Reports

Every Minions agent ends its dispatch by writing a JSON completion report. The report is the engine's primary, machine-readable signal that the work item is finished and is the source of truth for status, retry decisions, dashboard surfaces, and downstream automation. Fenced ` ```completion ` blocks in stdout are still parsed as a compatibility fallback, but the JSON file always wins when both exist.

This document is the canonical schema. Playbooks should cross-link here instead of restating the field list.

## Where the report is written

The engine injects the absolute path two ways before spawning each agent:

- `MINIONS_COMPLETION_REPORT` environment variable
- The same path appears in the rendered playbook prompt

Path shape (resolved by `shared.dispatchCompletionReportPath()` in `engine/shared.js`):

```
<MINIONS_DIR>/engine/completions/<dispatch-id>.json
```

The agent must write the JSON to that exact path before exiting. Any character outside `[a-zA-Z0-9._-]` in the dispatch id is replaced with `-` by the engine when computing the path.

## Trust boundary

Each spawn also receives a per-dispatch cryptographic value via the `MINIONS_COMPLETION_NONCE` environment variable. The engine generates this with `crypto.randomBytes(16).toString('hex')` in `engine.js:spawnAgent()` and stores it on the in-memory active-process record. The agent is required to copy the value verbatim into the report's `nonce` field. On parse, `engine/lifecycle.js:runPostCompletionHooks()` compares `report.nonce` against the in-memory value:

- **Match** — the report is trusted and processed normally.
- **Mismatch** — the report is treated as forged (a prompt-injected agent or a stale process writing into a sibling dispatch's completion path). Every signal it carries — `status`, `pr`, `noop`, `failure_class`, `retryable`, `needs_rerun`, fenced/summary fallbacks — is discarded. The dispatch is failed with `failure_class: 'completion-nonce-mismatch'` and the work item is marked failed (no auto-retry honors the agent's `retryable` claim).
- **Missing** — by default, the engine logs `[security] completion-nonce-missing dispatch=… required=false (degraded — report honored)` and still honors the report. Flip `ENGINE_DEFAULTS.completionNonceRequired` (or `engine.completionNonceRequired` in `config.json`) to `true` to hard-fail missing nonces too. Default is `false` for one release so older runtime caches and agents that haven't picked up the prompt change degrade with a warning instead of breaking.

Security event log lines are emitted on the `error` channel and are designed to be greppable:

```
[security] completion-nonce-missing dispatch=<id> agent=<name> wi=<wiId> required=<bool>
[security] completion-nonce-mismatch dispatch=<id> agent=<name> wi=<wiId> expected=<8char> got=<8char>
```

**Migration path.** Roll out in two phases:

1. **Phase 1 (default):** Ship the engine + playbook change with `completionNonceRequired: false`. Agents that echo the nonce are validated; agents that don't continue to work but emit a warning per dispatch. Watch the `[security] completion-nonce-missing` warnings drain to zero across runtimes.
2. **Phase 2:** Once warnings are quiet for at least one release window, flip `ENGINE_DEFAULTS.completionNonceRequired` to `true` (or set per-deployment via `config.json` → `engine.completionNonceRequired`). Missing nonces then hard-fail like mismatched ones.

Do **not** invent, regenerate, or share the nonce across dispatches — each spawn gets a unique value.

## Top-level schema

```json
{
  "status": "success",
  "summary": "what changed and how it was validated",
  "verdict": null,
  "pr": "PR id/url or N/A",
  "failure_class": "N/A",
  "retryable": false,
  "needs_rerun": false,
  "noop": false,
  "nonce": "<value of MINIONS_COMPLETION_NONCE env var>",
  "artifacts": [
    {"type": "pr", "path": "https://github.com/owner/repo/pull/123", "title": "PR-123"}
  ]
}
```

### Required fields

| Field | Type | Notes |
|---|---|---|
| `status` | string | One of `success`, `partial`, `failed`. Aliases `done` / `complete` are accepted on read for `success`. The engine refuses to mark a work item done without a status. |
| `summary` | string | Short prose describing what changed and how it was validated. Truncated to 500 chars in dashboard surfaces (`engine/queries.js`). Do not summarize validation as "tests passed" — name the commands that ran. |
| `verdict` | string \| null | Required for review tasks: `approved` or `changes-requested`. `null` for non-review tasks. Aliases: `approve`, `request_changes`, `changes_requested`. |
| `pr` | string | PR URL, `PR-<number>`, or `N/A`. The engine uses this to attach the PR to the work item; missing-PR detection treats anything other than a recognizable URL/PR id as missing unless `noop: true` is set. |
| `failure_class` | string | One of the `failure_class` enum values below, or `N/A`. Drives retry policy in `engine/dispatch.js`. |
| `retryable` | boolean | `true` if the engine should auto-retry the dispatch on failure. Overrides the default per-class retry policy when present. |
| `needs_rerun` | boolean | `true` if the same work needs to be re-dispatched (vs. retried). Used by build-fix and review-fix loops. |
| `artifacts` | array | Durable artifacts the agent created or updated; surfaces in the dashboard work-item detail modal. See [Artifacts](#artifacts). |
| `nonce` | string | Per-spawn cryptographic value the engine injects via `MINIONS_COMPLETION_NONCE`. Copy the env var value verbatim; the engine validates it on read. See [Trust boundary](#trust-boundary). |

### Optional fields

| Field | Type | Notes |
|---|---|---|
| `noop` | boolean | Canonical no-op signal. See [No-op semantics](#no-op-semantics). |
| `noopReason` | string | Human-readable rationale shown when `noop: true`. Falls back to `summary` if absent. |
| `files_changed` | string \| array | Comma-separated list (or array) of key files changed. |
| `tests` | string | `pass`, `fail`, `skipped`, `N/A`, or a free-form note like `skipped — relying on PR pipeline`. |
| `pending` | string | Any remaining work, or `none`. |
| `followups` | array | Optional. PR-comment follow-up work items the agent dispatched via `POST /api/work-items` with `meta.pr_followup` set. Each entry: `{wi_id, title, reason, parent_comment_id}`. See [PR-comment follow-ups](#pr-comment-follow-ups). |
| `meta.review` | object | Optional, review tasks only. Records project-local review-skill outcome — see [Review skill outcomes](#review-skill-outcomes). Aliased by the generalized `meta.skill` (W-mq1cczi90006b21f). |
| `meta.skill` | object | Optional. Records project-local skill outcome for ANY playbook type that surfaces a `## Project skills` block (implement / fix / plan / review / etc.). See [Project skill outcomes](#project-skill-outcomes). |
| `meta.descriptionAudit` | object | Optional, `fix` / `implement` dispatches that push commits. Records the PR description audit + screenshot-refresh outcome — see [PR description audit](#pr-description-audit). |

## Project skill outcomes

W-mq1cczi90006b21f. Generalizes the PR-82 review-only `meta.review.skill*` fields to the full Minions lifecycle. When the playbook renders a `## Project skills` block (via `engine/playbook-intents.js` → `engine/discover-project-skills.js`) and the agent acts on a discovered skill, record the outcome under `meta.skill` so later evaluation can compare skill-driven vs. first-principles work across implement / fix / plan / review dispatches uniformly.

All `meta.skill` fields are optional and backward-compatible — older agents that never set them stay valid.

```json
{
  "status": "success",
  "meta": {
    "skill": {
      "invoked": {
        "name": "bump-fmf-android",
        "path": ".claude/skills/bump-fmf-android/SKILL.md",
        "kind": "skill",
        "intent": "build"
      },
      "findings": 3
    }
  }
}
```

| Field | Type | Notes |
|---|---|---|
| `meta.skill.invoked` | object | The project skill the agent actually ran. Shape: `{name, path, kind, intent}` where `kind` is one of `skill`, `command`, `slash-command` (mirrors the discovery layer in `engine/discover-project-skills.js`) and `intent` is the bucket it served (`build`, `review`, `fix`, `test`, `plan`, `research`, `deploy`, `observability`, `meta`). Omit when no skill was invoked. |
| `meta.skill.findings` | number | Count of findings/artifacts the invoked skill returned. Semantics is per-skill; treat as opaque integer. Omit when no skill was invoked. |
| `meta.skill.skipped` | object | Set when a project skill was available but the agent intentionally chose not to run it (trivial diff, out-of-scope, meta-work on the skill itself, etc.). Shape: `{name, reason}`. Mirror the skip rationale into the PR comment / completion summary so a human sees the reasoning. |

Dispatchers can suppress the block entirely by setting `meta.skipProjectSkills: true` on the work item — the playbook then renders without the skills block. Use this for meta-work that targets the skill itself; the engine still accepts `meta.skill.skipped` in the report regardless. The PR-82 `meta.skipProjectReviewSkills` flag is preserved as an alias (suppresses the block on every dispatch type, not just review).

## Review skill outcomes

W-mq16xtdx001a347e. **Original PR-82 fields, preserved as aliases.** New code should prefer the generalized `meta.skill.*` fields above (W-mq1cczi90006b21f) — those work for every playbook type, not just review.

Review playbook renders a `## Project review skills` block at dispatch time when the target project ships `.claude/skills/*` or `.claude/commands/*` with `review` / `swarm` in the name or description (or when `.github/copilot-instructions.md` / `CLAUDE.md` mention a canonical `/…review…` slash-command). When the agent acts on that block, it records the outcome under `meta.review` so later evaluation can compare skill-driven reviews vs. first-principles reviews.

All `meta.review` fields are optional and backward-compatible — older agents that never set them stay valid.

```json
{
  "status": "success",
  "meta": {
    "review": {
      "skillInvoked": {
        "name": "code-reviewer",
        "path": ".claude/skills/code-reviewer/SKILL.md",
        "kind": "skill"
      },
      "skillFindings": 3
    }
  }
}
```

| Field | Type | Notes |
|---|---|---|
| `meta.review.skillInvoked` | object | The project review skill the agent actually ran. Shape: `{name, path, kind}` where `kind` is one of `skill`, `command`, `slash-command` (mirrors the discovery layer in `engine/discover-project-skills.js`). Omit when no skill was invoked. Aliased by `meta.skill.invoked`. |
| `meta.review.skillFindings` | number | Count of findings the invoked skill returned. Used later to measure skill quality and the value-add of first-principles review on top. Omit when no skill was invoked. Aliased by `meta.skill.findings`. |
| `meta.review.skillSkipped` | object | Set when a project review skill was available but the agent intentionally chose not to run it (trivial diff, out-of-scope diff, meta-review of the skill itself, etc.). Shape: `{name, reason}`. Aliased by `meta.skill.skipped`. |

Dispatchers can suppress the block entirely by setting `meta.skipProjectReviewSkills: true` on the review work item — the playbook then renders identically to the pre-W-mq16xtdx 8-step contract. Use this for meta-reviews of the review skill itself; the engine still accepts `meta.review.skillSkipped` in the report regardless. (W-mq1cczi90006b21f generalized this to `meta.skipProjectSkills`, which suppresses the block on every dispatch type — both flags are honored.)

## PR description audit

W-mq5l3f2u000i9045. The `fix` and `implement` playbooks inline a `## PR description audit` section that the agent runs **after** pushing commits to the PR's source branch and **before** marking the work item done. The audit re-reads the PR description, compares it against the diff of the newly-pushed commits, and patches stale sections (feature lists, config tables, file/line counts, ASCII diagrams, embedded screenshots) so reviewers don't end up reading outdated prose.

The audit is best-effort:

- Screenshot recapture requires a runnable dev server (detected via `package.json` scripts named `dev`, `start`, or `serve`) and Playwright MCP. When either is missing or fails, the agent skips the recapture without failing the work item.
- GitHub does not expose a documented REST endpoint for PR-comment image uploads; the agent skips the recapture on GitHub with `result: "screenshots-refreshed-skipped (gh-attachment-unavailable)"` instead of failing.
- Raw PNGs are saved under `agents/<id>/screenshots/` only. **Screenshots NEVER land in the repo** — adding any code path that `git add`s a PNG is a release-blocker bug.
- The audit never introduces screenshots to a PR that didn't already have any (refresh only), never modifies the PR title, never toggles draft state, and never posts a separate PR comment narrating the description change.

Record the outcome under `meta.descriptionAudit`. All fields are optional and backward-compatible — older agents that never set them stay valid.

```json
{
  "status": "success",
  "meta": {
    "descriptionAudit": {
      "ran": true,
      "result": "description-patched",
      "oldScreenshots": [],
      "newScreenshots": [],
      "patchedSections": ["feature-bullets", "config-table"]
    }
  }
}
```

| Field | Type | Notes |
|---|---|---|
| `meta.descriptionAudit.ran` | boolean | `true` if the agent executed the audit (whether or not it edited the description). `false` when the audit was suppressed (no commits pushed, no-op completion, `meta.skipDescriptionAudit` set, or the playbook skipped the audit for any reason). Omit `meta.descriptionAudit` entirely when the audit was not even considered. |
| `meta.descriptionAudit.result` | string | One of: `"no-changes-needed"`, `"description-patched"`, `"screenshots-refreshed"`, `"description-patched+screenshots-refreshed"`, or `"skipped:<reason>"` (e.g. `"skipped:meta-flag"`, `"skipped:no-commits"`, `"skipped:noop"`). The `"screenshots-refreshed-skipped (gh-attachment-unavailable)"` shape is also valid when GitHub's attachment limitation forces a partial-skip. |
| `meta.descriptionAudit.oldScreenshots` | string[] | URLs of image refs the agent replaced. Omit or `[]` when no screenshots changed. |
| `meta.descriptionAudit.newScreenshots` | string[] | URLs of newly uploaded image attachments. Omit or `[]` when no screenshots changed. |
| `meta.descriptionAudit.patchedSections` | string[] | Human-readable identifiers of the description sections that changed (e.g. `"feature-bullets"`, `"config-table"`, `"file-list"`, `"line-counts"`, `"ascii-diagram"`, `"screenshots"`). Omit or `[]` when no sections changed. |

Dispatchers can suppress the audit entirely by setting `meta.skipDescriptionAudit: true` on the work item — the playbook then renders an explicit "audit suppressed" notice instead of the audit steps. Use this for skill-meta updates, doc-only fixes whose PR description text won't be affected by the diff, and follow-up dispatches that explicitly own the description themselves.

## `failure_class` enum

Defined in `engine/shared.js` as `FAILURE_CLASS`. Use the canonical hyphenated string.

| Value | When to use | Default escalation |
|---|---|---|
| `config-error` | CLI not found, exit code 78, malformed config | Never retry |
| `permission-blocked` | Trust gate, permission denied, auth failure | Never retry |
| `merge-conflict` | Git merge conflict in worktree or dependency | Retry same agent |
| `build-failure` | Compilation, lint, or test failure introduced by the agent | Retry same agent |
| `timeout` | Hard runtime timeout or stale-orphan timeout | Retry with fresh session |
| `empty-output` | Agent produced no meaningful output | Flag for human review |
| `spawn-error` | Process failed to start or crashed immediately | Retry with fresh session |
| `network-error` | API rate limit, DNS, connectivity | Default retry logic |
| `out-of-context` | Context window exhausted | Flag for human review |
| `max-turns` | Claude CLI `error_max_turns` — work in progress | Retry same agent |
| `completion-nonce-mismatch` | Completion JSON missing or mismatched `nonce` (forged completion). See [Trust boundary](#trust-boundary). | Never retry (untrusted) |
| `unknown` | Unclassified failure | Default retry logic |

Use `"N/A"` when `status` is `success` or `partial` without a failure.

## No-op semantics

A no-op completion declares that the agent correctly **declined** to do the work — the change was already shipped on master, the dispatch premise was wrong, the flagged review comment was an author-note, etc.

To signal a no-op:

```json
{
  "status": "success",
  "summary": "PR #2372 Linux CI fix already shipped by parallel agent — nothing to do",
  "pr": "N/A",
  "noop": true,
  "noopReason": "Fix already on master at commit abc1234"
}
```

Engine behavior when `noop: true` (`engine/lifecycle.js` `parseCompletionNoop` + `runPostCompletionHooks`):

- The work item is marked `done` with the rationale stored as `_noopReason` (visible in the dashboard).
- The PR-attachment contract is **skipped**, so an empty `pr` field will not be flagged as a silent failure or auto-retried.
- Any non-success `status` combined with `noop: true` is treated as a contradiction and falls through to the normal contract.

Without `noop: true`, an empty PR field will be flagged as a missing-PR-attachment failure and auto-retried up to `ENGINE_DEFAULTS.maxRetries` times.

## PR-comment follow-ups

Fix and review agents can spin off new Minions work items in response to PR
comments that request **legitimate but out-of-scope** work. See
`playbooks/templates/followup-dispatch.md` for the full contract (decision
tree, exact `POST /api/work-items` shape, and required `meta.pr_followup`
fields). When the agent does so, it MUST record the dispatched WIs in the
optional `followups` array on its completion report:

```json
{
  "status": "success",
  "summary": "Fixed reviewer's blocking finding on PR #2400; dispatched W-mp9abcdef as out-of-scope follow-up.",
  "pr": "https://github.com/opg-microsoft/minions/pull/2400",
  "followups": [
    {
      "wi_id": "W-mp9abcdef",
      "title": "Extract markdown sanitizer into shared module",
      "reason": "Reviewer asked to track as separate WI (out of scope for #2400 — see comment 4567890)",
      "parent_comment_id": "4567890"
    }
  ]
}
```

Engine behavior (`engine/lifecycle.js` `processCompletionFollowups`):

- Each entry is logged at `info`: `Followup audit (<agent> wi=<parent>): dispatched <wi_id> — <title> (comment=<id>) — <reason>`.
- If the claimed `wi_id` does not exist in any project's `work-items.json` at parse time, a `warn` is emitted. This catches dispatches that were deduped to a 409 / 200-duplicate response, reverted, or never actually landed.
- The `followups` array is **descriptive only** — the engine does not auto-create or auto-link these WIs from the report. Creation happens at the moment the agent calls `POST /api/work-items`; the report is just the auditable record.
- Malformed entries (non-object, missing `wi_id`) are logged and skipped.

## `retryable` and `needs_rerun`

Both default to `false`. Together they let the agent override the engine's default retry policy:

- `retryable: true` — engine should treat the failure as transient and re-dispatch (subject to `maxRetries` and the per-class escalation policy).
- `retryable: false` — engine should not auto-retry; the failure is terminal for this dispatch.
- `needs_rerun: true` — even on `status: success`, the same work item should be re-dispatched (used by review-fix and build-fix loops where one pass cannot complete the work).

When the agent sets either field, it overrides the per-`failure_class` default in `isRetryableFailureReason()` (`engine/dispatch.js`).

## Artifacts

`artifacts` is an array of objects with this shape:

```json
{"type": "pr", "path": "https://github.com/owner/repo/pull/123", "title": "PR-123: Stale HEAD guard"}
```

| Field | Type | Notes |
|---|---|---|
| `type` | string | One of `note`, `plan`, `prd`, `pr`, `file`. |
| `path` | string | Relative repo path, absolute path, or URL. |
| `title` | string | Short label used in the dashboard list. |

The dashboard caps the rendered list at 20 artifacts per report (`engine/queries.js`). Include every durable artifact you created or updated — PRs, plan/PRD files, key source files, inbox notes — so the work-item detail modal can show them.

## Validation and source-of-truth precedence

The engine reads completion signals in this order (`engine/lifecycle.js`):

1. The JSON report at `MINIONS_COMPLETION_REPORT` — primary.
2. A fenced ` ```completion ` block in the agent's stdout — fallback only.
3. Process exit code and stdout heuristics — last-resort recovery.

If the JSON report exists and is well-formed, the engine ignores the fenced block. If the JSON is missing or malformed (logged as a warning), the engine falls back to the fenced block, then to stdout parsing.

## Examples

### Successful implement

```json
{
  "status": "success",
  "summary": "Added stale-HEAD guard to push step in spawn-agent.js; verified with new unit test test/unit/spawn-agent.test.js (4632 passed, 0 failed)",
  "verdict": null,
  "pr": "https://github.com/opg-microsoft/minions/pull/2360",
  "failure_class": "N/A",
  "retryable": false,
  "needs_rerun": false,
  "artifacts": [
    {"type": "pr", "path": "https://github.com/opg-microsoft/minions/pull/2360", "title": "PR-2360"},
    {"type": "file", "path": "engine/spawn-agent.js", "title": "Stale-HEAD guard"},
    {"type": "file", "path": "test/unit/spawn-agent.test.js", "title": "New unit test"}
  ]
}
```

### Successful review

```json
{
  "status": "success",
  "summary": "Verified completeRun sweep is inside mutateJsonFileLocked callback and pre-existing terminal stages are preserved.",
  "verdict": "approved",
  "pr": "https://github.com/opg-microsoft/minions/pull/2313",
  "failure_class": "N/A",
  "retryable": false,
  "needs_rerun": false,
  "artifacts": [
    {"type": "pr", "path": "https://github.com/opg-microsoft/minions/pull/2313", "title": "PR-2313"}
  ]
}
```

### Failed build

```json
{
  "status": "failed",
  "summary": "test/unit.test.js — c-c-multi-tab.test.js:409 fails on stale _joinCcPromptParts 4-arg signature; needs master rebase.",
  "verdict": null,
  "pr": "N/A",
  "failure_class": "build-failure",
  "retryable": true,
  "needs_rerun": false,
  "artifacts": []
}
```

### No-op

```json
{
  "status": "success",
  "summary": "Fix already shipped on master at commit eb2e7230 by parallel agent.",
  "pr": "N/A",
  "noop": true,
  "noopReason": "Linux CI fix already on master before this dispatch started"
}
```

## Related

- `engine/shared.js` — `FAILURE_CLASS`, `COMPLETION_FIELDS`, `dispatchCompletionReportPath()`
- `engine/lifecycle.js` — `parseCompletionReportFile()`, `parseCompletionNoop()`, `enforcePrAttachmentContract()`
- `engine/dispatch.js` — `isRetryableFailureReason()`, `writeFailedAgentReport()`
- `docs/rfc-completion-json.md` — original RFC describing the protocol's design
- `playbooks/shared-rules.md` — the per-task "Completion Reports" instruction every playbook inherits
