# Self-Improvement Spec — closed learning loop over registries

Audience: AI coding agents first. Canonical for A7 (supersedes its plan text) and plan PART IX. Depends on: `spec/RESOLVER.md` (LLM rung, action set), V4 stats (`src/stats.js`), A5 decision inbox (`spec/DAEMON.md`), X10 config, OKF (`spec/okf-format.md`).

## Principle

Improvement targets REGISTRIES (data files), NEVER code. Every learning is: proposed with evidence → approved (human, via decision inbox) where normative → applied with provenance → measured over a window → retained or retired. Advice without efficacy measurement is ballast — forbidden.

## Loop stages

`Observe → Attribute → Propose → Approve → Apply → Measure → Retire`

Observe = journal + stats (exists). This spec defines the rest.

## Learnable registries (closed list — extending this table is itself a normative change)

| surface | file | proposal may | approval |
|---|---|---|---|
| lessons | OKF job/project scope, `type: lesson` | add lesson; change lifecycle state | auto (non-normative) |
| taxonomy overlay | `src/fail-taxonomy.d/learned.json` | add rule `{pattern, failClass, scope, rung, provenance}` | HUMAN |
| resolver overrides | `src/resolver.d/learned.json` | add table row for (failClass, attempt) | HUMAN |
| watchdog budgets | repo `.harness.json` `watchdog.*` | tune per-state budget | HUMAN |
| lint rules | `src/lint.d/learned.json` | add WARN-severity rule only | HUMAN |
| router weights | `runstate/stats-cache.json` derived weights | recompute | auto (pure stats artifact) |
| prompt templates | `presets/prompts/*.md` (versioned files) | propose diff | HUMAN |

NEVER learnable (hard-coded refusal in the proposal generator): `.warnignore`, review skips, protected paths (V1), gate definitions, the resolver ACTION SET, this table.

Overlay loading: engine loads base registry + `*.d/learned.json`; learned rules eval AFTER base rules (base wins on first match); malformed overlay → fail-closed `plan-invalid`-style startup error, not silent skip. Every learned entry carries `{id, provenance: {runs, decisionId, adoptedTs}, claims: {metric, baseline}}`.

## Attribution (postmortem) — runs on every quarantine, before lesson write

One LLM call (cheapest model, same fail-closed rules as resolver rung). Schema:

```json
{
  "type": "object",
  "required": ["category", "summary", "evidence"],
  "properties": {
    "category": {"enum": ["plan-authoring-flaw","environment","agent-capability","flaky-infra","genuine-code-difficulty"]},
    "summary": {"type": "string", "maxLength": 300},
    "evidence": {"type": "string", "maxLength": 500}
  },
  "additionalProperties": false
}
```

Routing: `plan-authoring-flaw` → lint-rule proposal candidate. `environment` → taxonomy proposal candidate + lesson (NOT a repair-registry proposal — the repair registry is code, not a learnable registry). `flaky-infra` → W8 tracking (no lesson). `agent-capability` → router-weight signal + lesson. `genuine-code-difficulty` → lesson only. LLM unavailable → NO schema call happened; journal category `unattributed` (a journal-level value outside the LLM enum — the schema constrains LLM answers only), lesson only. Journal record: `postmortem {taskId, category, summary}` where `category` ∈ enum ∪ `unattributed`.

## Lesson lifecycle + efficacy

Lesson frontmatter adds ONE field: `lifecycle: candidate|validated|retired`. Injection/success counts live ONLY in `runstate/stats-cache.json` (recomputed from journals) — NEVER in frontmatter.

- **Injection rule:** fixer/resolver prompts get top-K lessons matching the fail-class, K=3, ranked: validated by efficacy DESC, then at most 1 candidate (newest). Retired lessons NEVER injected. Every injection journals `attempt.lessons: [lessonIds]` on the attempt record.
- **Efficacy math (exact):** for lesson L over its injections into attempts of its fail-class:
  `eff(L) = (successes(L) + 1) / (injections(L) + 2)` (Laplace-smoothed success rate; success = that attempt's fix landed).
  `baseline(class) = (successes_without_L + 1) / (attempts_without_L + 2)` over the same window (last 90 days or last 50 attempts of the class, whichever is larger).
- **Transitions (evaluated by the maint plan, below):** candidate → validated when `injections ≥ 5 AND eff(L) > baseline`. candidate|validated → retired when `injections ≥ 5 AND eff(L) ≤ baseline`. Retirement keeps the file (history + stats), flips `lifecycle: retired`, removes it from the OKF index active section.
- Promotion to project scope (`okf promote`): only `validated` lessons, same fail-class seen in ≥2 distinct runs (unchanged from A7).

## Proposal + ledger

Proposal = `decision.requested {kind: "improvement-proposal", registry, change, evidence: {clusterSize?, effData?, statsWindow}, claims: {metric, current, expected}}` + handoff bundle. Generated ONLY by the maint plan or postmortem routing — never inline during a user run.

- **Taxonomy clustering:** group `unknown`-classified failures by stderr first-line signature (exact-match after stripping digits/paths: `s/[0-9]+/N/g`, `s|/[^ ]*|PATH|g`). Cluster size ≥3 across ≥2 runs → propose `{pattern: <escaped literal signature>, failClass: <postmortem-informed>, scope, rung}`. Proposed pattern MUST be anchored (`^`) and MUST NOT match any OTHER cluster's signatures (checked mechanically before proposing).
- **Resolver promotion:** LLM rung chose the same action for the same (failClass, attempt) ≥5 times, all `confidence: high`, ≥80% `resolve.outcome.success` → propose deterministic row. The LLM works itself out of a job per class.
- **Watchdog tuning:** per repo+state, `proposed = ceil(p99(duration) * 1.5)` when current budget < p99×1.2 (false-stall risk) or > p99×4 (slow detection); needs ≥20 samples.
- **Apply:** on approval, daemon writes the registry entry with provenance + appends to improvement ledger `runstate/improvements.db` (journal-v2 event): `{registry, entryId, decisionId, claims}`.
- **Measure + auto-revert-proposal:** maint plan compares each ledger entry's claimed metric over the post-adoption window (≥10 relevant events or 30 days) vs its recorded baseline. Worse → propose revert (same inbox flow, evidence attached). Reverts remove the learned entry, ledger records it; the underlying lesson/history stays.

## `maint-self-improve` plan

Housekeeping plan (X4 ladder member, slug prefix `maint-`), runs weekly in the maintenance window: 1) recompute lesson lifecycles; 2) generate taxonomy/resolver/watchdog proposals from stats; 3) evaluate ledger entries → revert proposals; 4) emit digest section (proposals filed, lessons validated/retired, unknown-cluster count trend). It is a normal plan: journaled, resumable, chaos-testable. The `unknown` bucket's shrink rate is the headline self-healing maturity metric — print it.

## Guardrails

- HUMAN approval rows: the daemon MUST NOT auto-answer these decisions even with `default` present at any autonomy level (proposal decisions carry no `default` — generator MUST NOT set one).
- Prompt-injection defense: agent-produced text (postmortem summaries, lesson bodies) NEVER becomes an executable pattern/rule without the mechanical checks above + human approval. Lesson bodies are inert prompt context only.
- Registry entries are append/remove whole-entry — never edited in place (ledger provenance would lie).

## Tests

1. Efficacy math: fixture attempt histories → exact eff/baseline values; lifecycle transitions at the stated thresholds.
2. Injection rule: K-cap, retired exclusion, `attempt.lessons` journaled.
3. Clustering: fixture unknown failures → signatures, cross-cluster-collision rejection, anchored pattern output.
4. Overlay loading: base-wins ordering; malformed overlay fail-closed.
5. Ledger: apply → measure (fixture stats) → revert-proposal on regression.
6. Guardrail: proposal decision with injected `default` → daemon refuses to auto-answer (assert at level 3).
7. Chaos: C2 extended — quarantine produces `postmortem` record + lesson; second identical run's fixer prompt contains the lesson id in `attempt.lessons`.
