# Hermes Phase 2 — Custom Maintenance Jobs · Main Implementation Plan

**Spec:** `docs/changes/hermes-phase-2-custom-jobs/proposal.md`
**Date:** 2026-05-17
**Estimated effort:** 4–5 weeks, sequenced across 5 task tracks

## Task Order Rationale

Tracks land in dependency order so that each successor track's types and exports are available when its callers integrate. Track A introduces types + schema + validator (no behavior change); Track B wires the persistence layer; Track C adds the new execution surfaces (`checkScript`, `contextFrom`, `inlineSkills`) and threads them into `TaskRunner`. Tracks D and E are parallelizable after Track A (D depends on `OsvClient` types only, E touches only `cleanup-sessions`).

```
Track A (Types + Schema + Validator)
  ├→ Track B (Output Store + Scheduler resolveTasks)
  │    └→ Track C (CheckScript + Context Resolver + TaskRunner integration)
  │         └→ Track F (CLI surface + dashboard origin column)
  ├→ Track D (OSV Client + mcp-guard CLI + setup-mcp integration)
  └→ Track E (cleanup-sessions expansion)

Track G (Knowledge + ADR + AGENTS.md + CHANGELOG + plugin regen) runs last
```

Each task ends in a green `pnpm typecheck && pnpm test --filter <touched package>` before the next starts. Architecture and dependency checks run at the end of each track.

---

## Track A — Types + Schema + Validator

Foundation. Lands the wire shapes and config schema additions without changing runtime behavior. After this track, `harness.config.json` can declare `customTasks`, `cleanup`, and `osvGuard` sections that pass schema validation, but the scheduler still ignores them.

### Task A1 — Public types: `CustomTaskDefinition` + extended `MaintenanceConfig`

**Files:** `packages/types/src/maintenance.ts`, `packages/types/src/index.ts`

**Changes:**

- Add `export interface CustomTaskDefinition` with the fields from spec §"Schema additions" (type, description, schedule, branch, checkCommand?, checkScript?, fixSkill?, inlineSkills?, inlineSkillsBudgetTokens?, contextFrom?, contextFromMaxAgeMinutes?, outputRetention?, costCeiling?). Optional fields explicitly typed `| undefined`.
- Add `export interface CheckScriptDefinition { path: string; args?: string[]; parseStdoutJson?: boolean; timeoutMs?: number }` to share between public + internal types.
- Add `export interface OutputRetentionConfig { runs?: number; maxAgeDays?: number }`.
- Extend `MaintenanceConfig` with `customTasks?: Record<string, CustomTaskDefinition>`.
- Add `export interface CleanupConfig` covering per-directory TTLs.
- Add `export interface OsvGuardConfig { enabled?: boolean; strict?: boolean; cacheTtlHours?: number }`.
- Re-export new types from `packages/types/src/index.ts`.

**Verification:** `pnpm --filter @harness-engineering/types build` succeeds; `pnpm typecheck` from repo root succeeds.

### Task A2 — Internal types: extended `TaskDefinition` + `RunResult.origin`

**Files:** `packages/orchestrator/src/maintenance/types.ts`

**Changes:**

- Add optional `outputDir?: string` (defaults computed by output store), `contextFrom?: string[]`, `contextFromMaxAgeMinutes?: number`, `inlineSkills?: string[]`, `inlineSkillsBudgetTokens?: number`, `checkScript?: CheckScriptDefinition` to `TaskDefinition`.
- Add `export type RunOrigin = 'cron' | 'cli' | { kind: 'api'; tokenName: string } | { kind: 'chain'; upstreamTaskId: string }`.
- Add optional `origin?: RunOrigin` on `RunResult`.
- Add re-export of `CheckScriptDefinition` from `@harness-engineering/types`.

**Verification:** `pnpm typecheck` succeeds; existing dashboard rendering paths fall back to `origin ?? '—'` (unchanged wire shape because field is optional).

### Task A3 — Zod schema additions

**Files:** `packages/cli/src/config/schema.ts`

**Changes:**

- Add `CustomTaskDefinitionSchema` with discriminated `refine()` enforcing per-type required fields (`mechanical-ai` requires `branch` + (`checkScript` xor `checkCommand`) + `fixSkill`; `pure-ai` requires `branch` + `fixSkill`; `report-only` requires `checkScript` xor `checkCommand`; `housekeeping` requires `checkScript` xor `checkCommand`).
- Add `CheckScriptDefinitionSchema`, `OutputRetentionConfigSchema`, `CleanupConfigSchema`, `OsvGuardConfigSchema`.
- Extend the existing `MaintenanceConfigSchema` with `customTasks: z.record(z.string().regex(/^[a-z0-9-]+$/), CustomTaskDefinitionSchema).optional()`.
- Extend the top-level `HarnessConfigSchema` with optional `cleanup` and `osvGuard` sections.
- Snapshot tests covering: minimal valid custom task per task type; rejection cases (missing required field, wrong type, both `checkCommand` and `checkScript` provided, neither when required, invalid cron, name pattern violation).

**Verification:** `pnpm --filter @harness-engineering/cli test packages/cli/tests/config` passes (new + existing tests).

### Task A4 — Custom-task validator (cycle detection + skill existence)

**Files:** `packages/orchestrator/src/maintenance/custom-task-validator.ts` (new), `packages/orchestrator/tests/maintenance/custom-task-validator.test.ts` (new).

**Changes:**

- `export function validateCustomTasks({ customTasks, builtIns, skillExists, scriptExists }): Result<void, ValidationError[]>`.
- Build directed graph from `contextFrom` edges; DFS with grey/black coloring; emit path on cycle.
- Validate `inlineSkills` against an injected `skillExists(name): boolean` predicate.
- Validate `checkScript.path` against `scriptExists(path): boolean` (defers fs detail to caller — keeps the module pure).
- Tests cover: empty config, single valid task, cycles of length 1/2/N, fork-and-merge graphs, missing skill, missing script, mixed valid + invalid.

**Verification:** `pnpm --filter @harness-engineering/orchestrator test custom-task-validator` passes.

### Task A5 — Loader wiring

**Files:** `packages/cli/src/config/loader.ts`, plus orchestrator startup entry.

**Changes:**

- After zod parse, run `validateCustomTasks()` against the resolved config; surface errors with `Err` and exit code on the CLI path.
- Orchestrator startup invokes the validator before instantiating the scheduler.

**Verification:** Integration test: a bad config (cycle in `contextFrom`) makes `harness validate` exit non-zero with a clean error message.

---

## Track B — Output Store + Scheduler Integration

Wires per-task output persistence and merges custom tasks into the resolved task list. No runtime behavior change for built-ins.

### Task B1 — `TaskOutputStore`

**Files:** `packages/orchestrator/src/maintenance/output-store.ts` (new), `packages/orchestrator/tests/maintenance/output-store.test.ts` (new).

**Changes:**

- `export class TaskOutputStore` with constructor `({ rootDir, fs?, retentionDefaults? })`.
- `write(taskId, entry: PersistedOutputEntry)`: writes JSON to `<rootDir>/<taskId>/outputs/<iso>.json`; applies retention after write (last-N + maxAgeDays).
- `latest(taskId): PersistedOutputEntry | null`.
- `list(taskId, limit, offset): PersistedOutputEntry[]`.
- `get(taskId, runId): PersistedOutputEntry | null`.
- `PersistedOutputEntry` type shape per spec §D2.
- Use Node `fs/promises` directly; accept an injected `fs` for tests over a tempdir.

**Verification:** Unit tests cover retention by count, retention by age, atomic write, list pagination, missing-task fallback, corrupt entry skipping.

### Task B2 — `Scheduler.resolveTasks()` honors `customTasks`

**Files:** `packages/orchestrator/src/maintenance/scheduler.ts`, `packages/orchestrator/tests/maintenance/scheduler.test.ts`.

**Changes:**

- `resolveTasks()` returns `[...BUILT_IN_TASKS_with_overrides, ...customTasksFromConfig]`.
- Custom tasks honor the same `tasks.<id>.enabled === false` override convention (operators can disable a custom task without removing it).
- Order: built-ins first, then customs in `Object.entries` order (stable across Node 12+).
- Test: a config with 2 custom tasks resolves to `BUILT_IN_TASKS.length + 2`; disabled custom is excluded.

**Verification:** Existing scheduler tests pass; new tests pass.

### Task B3 — Origin tagging in scheduler

**Files:** `packages/orchestrator/src/maintenance/scheduler.ts` + scheduler integration glue.

**Changes:**

- `processQueue()` injects `origin: 'cron'` into the run record before calling `onTaskDue`.
- Manual-trigger paths (Task F1) inject `origin: 'cli'` / `origin: { kind: 'api', tokenName }` / `origin: { kind: 'chain', upstreamTaskId }`.

**Verification:** Scheduler test asserts `recordRun()` receives a `RunResult` with `origin: 'cron'`.

### Task B4 — Reporter persistence wiring

**Files:** `packages/orchestrator/src/maintenance/reporter.ts` (or equivalent — locate in code), `packages/orchestrator/src/orchestrator.ts`.

**Changes:**

- After each run completes, the existing reporter writes the run history entry AND `TaskOutputStore.write()` is invoked with stdout/stderr/structured/origin.
- Bind the output store as a singleton on the orchestrator, keyed by `cwd`.

**Verification:** Integration test: a fake task completes; output file appears at `.harness/maintenance/<id>/outputs/<iso>.json`.

---

## Track C — CheckScript + Context Resolver + TaskRunner Integration

The new execution surfaces. Built-ins still use `checkCommand`; custom tasks may use `checkScript`. `contextFrom` and `inlineSkills` flow into agent dispatch.

### Task C1 — `CheckScriptRunner`

**Files:** `packages/orchestrator/src/maintenance/check-script-runner.ts` (new), `packages/orchestrator/tests/maintenance/check-script-runner.test.ts` (new).

**Changes:**

- `export class CheckScriptRunner` implementing the existing `CheckCommandRunner` interface (so it's a drop-in substitute when `checkScript` is set).
- `run(spec: CheckScriptDefinition, cwd): Promise<CheckScriptResult>` returns `{ passed, findings, output, structured?, status }`.
- Parses the **last** non-empty stdout line; if it parses as JSON with a recognized `status`, returns the structured form; otherwise applies the existing heuristic regex.
- Recognized statuses: `ok`, `findings`, `skip`, `error`. `wakeAgent` field (default `findings > 0`) controls whether the runner reports `passed: false` (triggering agent dispatch).
- Uses `execFile` with a timeout from `spec.timeoutMs ?? 120_000`.

**Verification:** Unit tests cover all four status values, missing/malformed JSON, timeout, non-executable path, large stdout.

### Task C2 — Context Resolver

**Files:** `packages/orchestrator/src/maintenance/context-resolver.ts` (new), `packages/orchestrator/tests/maintenance/context-resolver.test.ts` (new).

**Changes:**

- `export class ContextResolver({ outputStore, skillRegistry, logger })`.
- `resolveContextFrom(taskIds, options): Promise<string>` reads each upstream's latest output, formats per spec §Flow 1, applies `maxAgeMinutes` filter, marks stale/missing entries.
- `resolveInlineSkills(skillNames, budgetTokens): Promise<string>` reads each skill markdown body via the registry, applies a character-count budget (4 chars ≈ 1 token), truncates skill-granularly, logs `[maintenance] inlineSkillsBudgetTokens (N) exhausted after K of M skills; truncated.`
- Tests cover: stale upstream → omitted with marker; missing upstream → omitted with marker; budget exhaustion mid-skill → log + truncate; skill registry miss → warn + skip.

### Task C3 — TaskRunner integration

**Files:** `packages/orchestrator/src/maintenance/task-runner.ts`, tests.

**Changes:**

- `TaskRunnerOptions` accepts `contextResolver?: ContextResolver`, `checkScriptRunner?: CheckCommandRunner`, `outputStore?: TaskOutputStore` (all optional for back-compat with existing tests).
- `runMechanicalAI` + `runPureAI`: if task declares `contextFrom`/`inlineSkills` and a resolver is present, compose a `promptContext: string`. Pass through to `agentDispatcher.dispatch(skill, branch, backend, cwd, { promptContext })` via extended `AgentDispatcher.dispatch` signature.
- The `AgentDispatcher.dispatch` signature gains an optional 5th argument `{ promptContext?: string }`; existing in-tree dispatcher (orchestrator.ts) accepts but ignores it for now (Track C is incremental — Track F can wire the prompt through to the agent backend).
- For `mechanical-ai` tasks: if `task.checkScript` is set, run via `checkScriptRunner.run(task.checkScript, cwd)` instead of `checkRunner.run(task.checkCommand, cwd)`.
- For `report-only` + `housekeeping`: same `checkScript` swap.
- Persist output via `outputStore?.write` at end of each path.

**Verification:** Existing TaskRunner tests pass; new tests cover: `checkScript` mechanical-ai path; `contextFrom` injection; `inlineSkills` budget exhaustion.

### Task C4 — Orchestrator wiring

**Files:** `packages/orchestrator/src/orchestrator.ts`.

**Changes:**

- `createMaintenanceTaskRunner()` constructs `OutputStore`, `ContextResolver`, `CheckScriptRunner` and injects them into the `TaskRunner`.
- Bind orchestrator paths so manual trigger + scheduled trigger both flow through the same runner.

**Verification:** Integration smoke test: a custom `mechanical-ai` task with `checkScript` + `contextFrom` + `inlineSkills` end-to-end (in-process; agent dispatcher mocked).

---

## Track D — OSV Pre-launch Guard

Independent of A/B/C after Task A1 (which exports `OsvGuardConfig` from `@harness-engineering/types`).

### Task D1 — `OsvClient`

**Files:** `packages/core/src/security/osv-client.ts` (new), `packages/core/tests/security/osv-client.test.ts` (new).

**Changes:**

- `export interface OsvClient { check(pkg): Promise<OsvCheckResult> }`.
- `export function createOsvClient({ cacheDir, cacheTtlHours, fetchFn? })`.
- Implementation: cache lookup → on miss, POST to `https://api.osv.dev/v1/query` with `{ package: { name, ecosystem }, version }`; on success, write cache; classify advisories by `id` prefix (`MAL-*` → `malicious`, else `other`).
- On network error: log warning, return `{ malicious: [], other: [], source: 'fail-open' }` (unless `strict` is set on the caller's side).
- 24h TTL; cache key = `${ecosystem}-${name}@${version ?? 'latest'}.json`.

**Verification:** Tests mock `fetchFn` and a tempdir; cover cache hit, cache miss, malicious match, non-malicious advisory, network failure, malformed response.

### Task D2 — `harness mcp-guard` CLI command

**Files:** `packages/cli/src/commands/mcp-guard.ts` (new), `packages/cli/src/commands/_registry.ts`, tests.

**Changes:**

- `harness mcp-guard check [--strict] [--json]`: reads `.mcp.json`, iterates `mcpServers`, calls `OsvClient.check` for each `npx`-style entry (extracts package name + version from `args`).
- `harness mcp-guard cache clear`: removes `.harness/cache/osv/`.
- Output: tabular when `--json` absent, JSON envelope when present.
- Exit code: 0 if all clean, 2 if any malicious match, 1 on usage error.

**Verification:** Tests cover: clean `.mcp.json` → exit 0; injected malicious match (mock OsvClient) → exit 2; cache-clear; `--json` shape.

### Task D3 — `setup-mcp` integration

**Files:** `packages/cli/src/commands/setup-mcp.ts`.

**Changes:**

- Before writing each new entry, call `OsvClient.check`.
- If `malicious.length > 0`, abort with non-zero exit and print advisory details. Honor `--skip-osv-guard` to override (logs a warning).
- Existing config-write paths unchanged on the happy path.

**Verification:** Tests cover: malicious package rejected; clean package proceeds; `--skip-osv-guard` flag overrides with logged warning.

### Task D4 — Hook script + plugin manifest entry

**Files:** Plugin manifest templates under `templates/`, slash-command generator.

**Changes:**

- Add a `pre-mcp-launch` hook entry to host plugin manifests (Claude Code, Cursor, Codex, Gemini, OpenCode) that runs `harness mcp-guard check --pkg "$PKG"` and aborts launch on non-zero.
- Re-run slash-command generator to materialize the manifests.

**Verification:** Generated manifest snapshots include the hook entry; existing CI snapshot tests pass after refresh.

---

## Track E — Cleanup-Sessions Expansion

Independent of A/B/C/D.

### Task E1 — Per-directory rule table + flags

**Files:** `packages/cli/src/commands/cleanup-sessions.ts`, `packages/cli/tests/commands/cleanup-sessions.test.ts`.

**Changes:**

- Promote the hardcoded `STALE_TTL_MS` into a `CleanupTarget` record with name + relative path + default TTL.
- Add `--all`, `--include <list>`, `--exclude <list>` flags (mutually-exclusive at the CLI layer).
- Read `cleanup` section from `harness.config.json` (if present) to override per-target TTLs.
- Preserve no-flag behavior: only `.harness/sessions/` is swept at 24h TTL.
- Dry-run output expands to one section per target.

**Verification:** Tests over a tempfs cover: no-flag default, `--all`, `--include cache,outputs`, `--exclude sessions`, config-driven TTL override, dry-run output shape.

---

## Track F — CLI Surface + Dashboard Origin Column

Depends on Track C. Lower priority; can ship behind a feature flag if Track C runs long.

### Task F1 — `harness maintenance` command

**Files:** `packages/cli/src/commands/maintenance.ts` (new), `packages/cli/src/commands/_registry.ts`, tests.

**Changes:**

- `harness maintenance list [--json]`: lists resolved tasks with badges (`built-in` / `custom`), schedule, last run summary.
- `harness maintenance run <task-id> [--dry-run]`: invokes the runner with `origin: 'cli'`; aborts with clear error if the task doesn't exist or its config is invalid.
- `harness maintenance show <task-id> [--limit N] [--json]`: reads from `TaskOutputStore`; prints last N runs.

**Verification:** Tests cover all three subcommands against an injected fake orchestrator + output store.

### Task F2 — Dashboard `origin` column

**Files:** `packages/dashboard/src/client/pages/maintenance/MaintenancePage.tsx`, supporting types.

**Changes:**

- Render `origin` column in the history table; type-safe switch.
- Render a `built-in` / `custom` badge per row by joining against the resolved task list.

**Verification:** Existing dashboard snapshot tests refresh; new test renders all four `origin` variants.

---

## Track G — Knowledge + Docs + Plugin Regeneration

Final track, after everything else lands. No code changes outside `docs/`.

### Task G1 — Knowledge graph artifacts

**Files:**

- `docs/knowledge/orchestrator/custom-maintenance-jobs.md` (new)
- `docs/knowledge/cli/pre-launch-osv-guard.md` (new)
- `docs/knowledge/cli/disk-hygiene.md` (new or extension)

**Verification:** `harness:knowledge-pipeline` ingestion produces the new business_process / business_concept / business_rule nodes per spec §"Knowledge Impact".

### Task G2 — ADR

**Files:** `docs/knowledge/decisions/custom-maintenance-task-model.md`.

**Verification:** Soundness-review against the ADR.

### Task G3 — AGENTS.md + README + CHANGELOG + Changeset

**Files:** `AGENTS.md`, `README.md`, `CHANGELOG.md`, `.changeset/<name>.md`.

**Verification:** `pnpm changeset status` shows the new entry; `harness validate` passes.

### Task G4 — Plugin manifest regeneration

**Files:** Various under `.claude-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `.gemini-extension/`.

**Verification:** Generated outputs include new slash commands for `maintenance` + `mcp-guard`.

---

## Final verification

After all tracks merge:

```bash
pnpm typecheck
pnpm test
pnpm exec harness validate
pnpm exec harness check-arch
pnpm exec harness check-deps
```

All must be clean. Then `harness:verification` three-tier (EXISTS / SUBSTANTIVE / WIRED), then `harness:integration`, then `harness:code-review`, then ship.

---

## Risks & Mitigations

| Risk                                                                          | Mitigation                                                                                                 |
| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `contextFrom` graph grows large enough to wedge prompt budgets                | `inlineSkillsBudgetTokens` + `contextFromMaxAgeMinutes` defaults; warn-then-truncate                       |
| OSV.dev rate-limits during CI                                                 | 24h disk cache; only check distinct package@version pairs; fail-open                                       |
| `checkScript` JSON parsing breaks on noisy stdout                             | Scan from last line backward for parseable JSON (mirrors existing `parseStatusLine` in `task-runner.ts`)   |
| Custom task with `fixSkill` collides with built-in skill name                 | Skill registry lookup is namespace-scoped; collision surfaces as a validator error at config-load          |
| Cleanup-sessions `--all` removes a directory operator considered live         | Whitelist of registered dirs only; unknown subdirectories of `.harness/` are preserved; dry-run by default |
| `TaskOutputStore` retention thrash on high-frequency tasks (e.g., 1-min cron) | Retention runs after write but is bounded by N; mtime-based age check is O(N) per task only                |

## Deferral choices for this iteration

Per spec §"Trigger conditions for re-decomposing":

- **API routes** (Track F's external surface — `POST /api/v1/jobs/maintenance/{taskId}/trigger` and friends): deferred. Phase 0's Gateway API contracts are not yet stable enough to commit to a wire shape; the CLI + scheduled paths cover the v1 use cases. Track F1 ships the CLI; the API routes land in a follow-up after Phase 0's contracts settle.
- **MCP tools** (`run_maintenance_task`, `read_maintenance_output`): deferred for the same reason. The CLI surface is the v1 external interface; MCP-tool wrappers are thin once the CLI shape is stable.
- **Sandbox isolation for `checkScript`**: explicit Non-goal in the spec. Phase 5's isolation-tier covers this when it lands.
