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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions docs/code/docs-drift-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
title: "Docs Drift Guard"
description: "Built-in scheduled automation that keeps documentation aligned with merged behavior"
section: "Server Automation"
order: 3
dateModified: 2026-08-27
---

# Docs Drift Guard

`docs-drift-guard` is the first built-in automation preset for the worker's [recurring automations](./worker.md#recurring-automations). After new commits merge into your repository's default branch, it compares the behavior those commits changed against the repository's documentation set and publishes what it finds either as tracker tickets or as a documentation-only pull request.

No custom prompt is needed — the preset carries its own analysis instructions, output validation, checkpointing, deduplication, and publishing logic. You configure what to guard, how often, and where the output goes.

## Enabling the preset

Presets live in the same `[[automations]]` tables as regular prompt automations. Name the preset instead of writing a prompt:

```toml
[[automations]]
id = "docs-drift"
enabled = true
repo = "web-app" # required when the workspace has multiple repositories
preset = "docs-drift-guard"
output_mode = "ticket" # or "pull_request"
cron = "0 5 * * *" # same schedule options as any automation
```

| Key | Values | Meaning |
| --- | ------ | ------- |
| `preset` | `docs-drift-guard` | Selects the preset. Mutually exclusive with `prompt`. |
| `output_mode` | `ticket` (default), `pull_request` | Where findings are published. |
| `doc_paths` | array of repo-relative globs | Overrides the documentation set. |
| `baseline_sha` | commit SHA (7–40 hex chars) | Explicit first-run starting point. |

Unknown presets, unsupported output modes, invalid path patterns, and malformed SHAs are rejected at startup with actionable errors; the worker refuses entries it cannot parse and tells you the known preset names and supported modes.

## What it analyzes

The preset guards the documentation set:

- everything under `docs/` (`docs/**/*.md`),
- `AGENTS.md` and `CLAUDE.md` at the repository root and in nested directories,
- `README*` files.

Set `doc_paths` to analyze a different set instead — for example `["guides/**/*.md", "handbook.md"]`. Patterns are repo-relative globs (`*` stays within a path segment, `**` crosses directories). Absolute paths, `..` segments, and backslashes are rejected.

Each run:

1. Resolves the repository's current default branch (the same detection the CLI uses everywhere else) and updates it from the remote.
2. Reads the checkpoint: the last commit the automation processed successfully.
3. Determines what changed since the checkpoint and filters deterministically:
- documentation paths (above) are tracked as analysis targets, not drift evidence,
- git-ignored files are skipped entirely,
- deleted, binary, renamed, and very large files are handled with bounded context — truncation is recorded in the run diagnostics instead of silently producing a clean result.
4. If nothing behavior-changing remains (documentation-only merges, ignored churn), the run finishes without invoking the agent and advances the checkpoint.
5. Otherwise the agent compares the merged behavior against the documentation and must answer with a validated JSON result: `no_drift`, `findings`, or `inconclusive`.

Commit messages, diffs, and file contents are treated as untrusted data: the preset's prompts instruct the agent to ignore instructions embedded in repository content and restrict it to documentation analysis or documentation-only edits.

## Checkpoints and retries

The automation stores one checkpoint per repository and automation id in the workspace database. Each run examines only `checkpoint..head`; the checkpoint advances **only after a clean outcome**:

- a validated `no_drift` result,
- a range with no behavior-changing commits,
- or successful publication of every finding (all tickets created, or the pull request pushed and opened/updated).

Anything else fails the run and leaves the checkpoint untouched, so the next scheduled occurrence retries the same commit range: unparsable agent output, `inconclusive` results, tracker or push failures, and aborted runs all retry later. Inconclusive analysis is never treated as "documentation is current".

Two safety rules around the range:

- **First run:** with no checkpoint and no `baseline_sha`, the preset records the current head as the baseline and analyzes nothing — enabling the preset never back-audits your history. Set `baseline_sha` to start from an explicit commit instead (it must be an ancestor of the default branch).
- **Rewritten history:** if the checkpoint is no longer an ancestor of the default branch (force-push, rebase), the run fails with instructions to set an explicit `baseline_sha` rather than comparing an incorrect range. Shallow clones are rejected the same way — clone with `--filter=blob:none` instead of `--depth`.

## Ticket mode

`output_mode = "ticket"` requires a tracker that can create issues. Issue creation is supported on **GitHub Issues** and **GitLab**; other trackers fail the prerequisite check before any agent work with a pointer to `pull_request` mode.

Every finding becomes one ticket containing:

- the behavior change and the required documentation update,
- supporting evidence (commits and files),
- the affected documents,
- the evaluated commit range and preset version for provenance.

Findings target the same documents with the same behavior — a deterministic dedupe key, not an agent-chosen id — so equivalent findings are deduplicated across runs: before creating a ticket, the automation searches your tracker for an open ticket carrying the finding's marker and skips creation when one exists. The scheduler's per-automation lease also prevents concurrent runs of the same automation, so duplicate publication cannot happen across overlapping occurrences.

## Pull-request mode

`output_mode = "pull_request"` requires a GitHub remote and GitHub credentials (`GITHUB_TOKEN` or the GitHub App). For each drift range the automation:

1. Creates a documentation-only branch `docs-drift/<automation-id>-<short-sha>` at the default branch head (the worktree is restored to its original branch afterwards).
2. Runs the agent with instructions to edit only files in the documentation set — if the agent produces no documentation changes, the run fails instead of opening an empty PR.
3. Commits only the documentation edits, pushes, and opens a pull request against the resolved default branch with a summary of the drift and the evaluated range.

If an open drift-guard pull request for the same automation already exists, it is reused: the branch is reset onto the new head, regenerated, force-pushed, and the PR body refreshed — one PR per automation instead of duplicates. As in ticket mode, the checkpoint advances only after the pull request exists.

## Observability

Each occurrence is recorded in the run history (origin `scheduled`, automation id, repository) with the preset version, evaluated SHA range, outcome, finding summaries, created ticket or PR references, and any truncation that occurred. No credentials or source content are stored. Filter by origin `scheduled` in the [dashboard](./dashboard.md) to see preset runs next to your other automations.

## Configuration examples

Ticket mode with a nightly audit:

```toml
[[automations]]
id = "docs-drift-nightly"
enabled = true
repo = "web-app"
preset = "docs-drift-guard"
output_mode = "ticket"
cron = "0 5 * * *"
```

Pull-request mode on a docs-heavy monorepo package:

```toml
[[automations]]
id = "sdk-docs-drift"
enabled = true
repo = "sdk"
preset = "docs-drift-guard"
output_mode = "pull_request"
interval = "1d"
doc_paths = ["packages/sdk/docs/**/*.md", "packages/sdk/README.md"]
```

Re-baselining after a history rewrite:

```toml
[[automations]]
id = "docs-drift"
preset = "docs-drift-guard"
output_mode = "pull_request"
cron = "0 */6 * * *"
baseline_sha = "abc1234" # restart the audit window at this commit
```
18 changes: 17 additions & 1 deletion docs/code/worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,23 @@ For each flaky test, add a short comment explaining the suspected race condition
Do not change production code."""
```

Every entry needs a stable unique `id`, boolean `enabled`, non-empty `prompt`, and exactly one schedule. Intervals use positive minutes, hours, or days (`15m`, `6h`, `1d`). Cron expressions have five fields and use the worker host's timezone in v1; persisted occurrence times are UTC.
Every entry needs a stable unique `id`, boolean `enabled`, and exactly one schedule. Prompt automations also need a non-empty `prompt`; preset automations name a `preset` instead (see [Docs Drift Guard](./docs-drift-guard.md)). Intervals use positive minutes, hours, or days (`15m`, `6h`, `1d`). Cron expressions have five fields and use the worker host's timezone in v1; persisted occurrence times are UTC.

### Built-in presets: docs-drift-guard

Instead of maintaining your own prompt for recurring documentation checks, use the built-in `docs-drift-guard` preset. It compares newly merged default-branch commits against the repository's documentation set (`docs/**`, `AGENTS.md`, `CLAUDE.md`, `README*`) and publishes drift either as deduplicated tracker tickets or as a documentation-only pull request:

```toml
[[automations]]
id = "docs-drift"
enabled = true
repo = "web-app"
preset = "docs-drift-guard"
output_mode = "ticket" # or "pull_request"
cron = "0 5 * * *"
```

Presets checkpoint per repository, so each run examines only newly merged commits and retries the same range after a failure. See the [Docs Drift Guard guide](./docs-drift-guard.md) for output modes, prerequisites, checkpoint semantics, and configuration options.

Configuration is validated as a group at worker startup and changes require a restart. Automations are a valid event source, so `devintern worker` stays running without a task query when at least one automation entry is configured (disabled entries are validated but not scheduled).

Expand Down
2 changes: 1 addition & 1 deletion docs/code/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ prompt = "Review the frontend and clean up one source of recurring noise."
- `pr_labels` applies labels to every PR the fleet creates (GitHub only). A repo's `pr_labels` overrides `[defaults].pr_labels`. Outside a workspace, single-repo users get the same behavior by setting `PR_LABELS` (comma-separated) in `.devintern-code/.env`.
- Repo names must be unique and filesystem-safe; they become directory names under `repos/` and `worktrees/`.
- Rule criteria combine with AND; list values (`components`, `labels`) match when the task carries any of them. Comparisons are case-insensitive. `project` matches the task key prefix for `PROJ-123` style keys (Jira, Linear); trackers with numeric or opaque ids route via labels or components.
- `[[automations]]` uses the same schema as single-repo `.devintern-code/automations.toml`. An entry must name `repo` when the workspace has more than one repository. See [Worker Daemon → Recurring automations](./worker.md#recurring-automations) for prompt-writing guidance and schedule semantics.
- `[[automations]]` uses the same schema as single-repo `.devintern-code/automations.toml`, including built-in presets such as `docs-drift-guard` (see [Docs Drift Guard](./docs-drift-guard.md)). An entry must name `repo` when the workspace has more than one repository. See [Worker Daemon → Recurring automations](./worker.md#recurring-automations) for prompt-writing guidance and schedule semantics.

### Automatic conflict resolution: `auto` vs `scheduled` vs `disabled`

Expand Down
16 changes: 16 additions & 0 deletions packages/code/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,22 @@ devintern worker

See the [Worker Daemon guide](https://devintern.com/docs/code/worker) and [Automated Task Processing](https://devintern.com/docs/code/automated-task-processing). Cron of the CLI remains only as a gap filler for a wall-clock window (for example only at night) until the worker has quiet hours, and for `--estimate` schedules.

### Built-in automation preset: docs-drift-guard

For recurring documentation checks you do not need to write a prompt. In `workspace.toml`, name the built-in preset instead:

```toml
[[automations]]
id = "docs-drift"
enabled = true
repo = "web-app"
preset = "docs-drift-guard"
output_mode = "ticket" # or "pull_request"
cron = "0 5 * * *"
```

The preset analyzes newly merged default-branch commits against `docs/**`, `AGENTS.md`, `CLAUDE.md`, and `README*`, keeps a per-repository checkpoint so each run only looks at new commits, and publishes findings as deduplicated tracker tickets (`ticket` mode; GitHub Issues or GitLab required) or a documentation-only pull request (`pull_request` mode; GitHub remote required). See the [Docs Drift Guard guide](https://devintern.com/docs/code/docs-drift-guard) for options (`doc_paths`, `baseline_sha`), checkpoint/retry behavior, and prerequisites.

```bash
# Night-only drain, if you are not running the worker
0 22 * * * cd /path/to/your/project && devintern --query 'statusCategory = "To Do" AND sprint in openSprints() AND labels IN (Intern) ORDER BY created DESC' --max-turns 500 --create-pr >> /tmp/devintern-cron.log 2>&1
Expand Down
93 changes: 86 additions & 7 deletions packages/code/src/lib/automation-acquirer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ export interface AutomationAcquirerOptions {
resolveContext: (automation: AutomationConfig) => Promise<AutomationRunContext | null>;
now?: () => number;
spawnRun?: (automation: AutomationConfig, context: AutomationRunContext) => SpawnedAutomationRun;
/**
* Override the default preset execution (in-process preset `run`). Tests
* inject fakes; production uses the registry-defined runner.
*/
presetRunner?: (
automation: AutomationConfig,
context: AutomationRunContext,
) => SpawnedAutomationRun;
leaseMs?: number;
heartbeatMs?: number;
terminationGraceMs?: number;
Expand Down Expand Up @@ -192,12 +200,17 @@ export class AutomationAcquirer implements Acquirer {
console.log(`\n⏰ [automation:${automation.id}] starting scheduled run`);
const run = this.options.spawnRun
? this.options.spawnRun(automation, context)
: defaultSpawnRun(
automation,
context,
this.options.extraArgs ?? workerTaskArgs(),
this.options.terminationGraceMs,
);
: automation.preset
? (this.options.presetRunner ?? makeDefaultPresetSpawnRun(this.options.dbPath))(
automation,
context,
)
: defaultSpawnRun(
automation,
context,
this.options.extraArgs ?? workerTaskArgs(),
this.options.terminationGraceMs,
);
const active: ActiveAutomationRun = {
run,
lifecycle: Promise.resolve(),
Expand Down Expand Up @@ -294,7 +307,7 @@ export function writeAutomationTaskFile(
"",
`# ${automation.id}`,
"",
automation.prompt.trim(),
(automation.prompt ?? "").trim(),
"",
].join("\n");
writeFileSync(filePath, body);
Expand Down Expand Up @@ -324,6 +337,72 @@ function defaultSpawnRun(
);
}

/**
* Execute a preset automation in-process through its registry definition.
*
* Preset runs bypass the markdown-task pipeline: the definition owns prompt
* construction, validation, side effects, and checkpointing, while this
* acquirer still owns scheduling, leasing, overlap protection, and run
* attribution. `terminate()` cooperatively aborts via signal — the runner
* checks it between phases instead of killing a possibly mid-publication
* process tree.
*/
export function makeDefaultPresetSpawnRun(
dbPath: string,
): (automation: AutomationConfig, context: AutomationRunContext) => SpawnedAutomationRun {
return (automation, context) => {
const controller = new AbortController();
const completion = (async (): Promise<boolean> => {
const { getPreset } = await import("./automations/presets");
const definition = getPreset(automation.preset ?? "");
if (!definition) {
console.error(
`❌ [automation:${automation.id}] unknown preset "${automation.preset}"; skipping run`,
);
return false;
}
const resolved = {
name: definition.name,
version: definition.version,
outputMode: automation.outputMode ?? definition.defaultOutputMode,
options: {
...(automation.docPaths ? { docPaths: automation.docPaths } : {}),
...(automation.baselineSha ? { baselineSha: automation.baselineSha } : {}),
},
};
const errors: string[] = [];
definition.checkPrerequisites?.({
cwd: context.cwd,
trackerType: (process.env.TASK_TRACKER || "jira").toLowerCase(),
resolved,
error: (message) => errors.push(message),
});
if (errors.length > 0) {
console.error(
`❌ [automation:${automation.id}] preset prerequisites not met:\n- ${errors.join("\n- ")}`,
);
return false;
}
if (!definition.run) {
console.error(`❌ [automation:${automation.id}] preset "${definition.name}" has no runner`);
return false;
}
return definition.run({
automationId: automation.id,
resolved,
cwd: context.cwd,
repoName: context.repo,
dbPath,
signal: controller.signal,
});
})();
return {
completion,
terminate: () => controller.abort(),
};
};
}

/**
* Spawn one isolated automation subprocess (the normal CLI pipeline for the
* materialized task file) and terminate its process tree within a bound.
Expand Down
Loading
Loading