reconcile

SkillAI & models

Use this skill when the user wants to reconcile learnings into rules, run /reconcile, propose rules from learnings, turn learnings into .claude/rules/ entries, or review what rules would be generated from current session learnings. On-demand version of session-end Phase 3.6.8.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the reconcile skill

What this skill tells your AI

The instructions your AI receives, as published by kanevry/session-orchestrator in skills/reconcile/SKILL.md and read by ahel’s review.

Platform Note: State files use the platform's native directory: .claude/ (Claude Code), .codex/ (Codex CLI), or .cursor/ (Cursor IDE). Shared metrics live in .orchestrator/metrics/. See skills/_shared/platform-tools.md.

Reconcile Skill

On-demand version of the session-end Phase 3.6.8 reconciliation flow. Turns eligible learnings from .orchestrator/metrics/learnings.jsonl into proposed .claude/rules/<slug>.md entries, presenting each batch of 4 to the coordinator via AUQ multiSelect for operator approval before any file is written. Advisory-only — rules are NEVER auto-applied.

Posture Contract (load-bearing — read before executing)

  • Advisory-only. No rule is ever written without explicit operator approval via AUQ. The AUQ multiSelect is the mandatory gate; there is no bypass.
  • Never-always-on invariant. The reconcile engine's emitter (emitter.mjs) throws on any eligible learning that would produce an alwaysApply: true rule — the engine structurally cannot emit always-on rules. This invariant is enforced upstream, not by this skill.
  • Engine never writes .claude/rules/. runReconcile computes proposals and records them in the idempotency sidecar only. The only module that writes .claude/rules/ is writer.mjs, and only AFTER the operator approves proposals via AUQ.
  • The candidate store belongs to mergeCandidates — nothing else writes it. .orchestrator/runtime/reconcile-candidates.jsonl is a mutable work-queue whose only sanctioned writer is mergeCandidates (scripts/lib/reconcile/idempotency.mjs); it is not a scratch pad, and no report, analysis run, or agent may append to it by hand. A hand-written record there corrupts downstream readers — the session-start reconcile nudge banner derives "last run" from created_at, so a foreign-shaped record makes a non-empty store report no reconcile run on record. Candidate analyses and dry-run reports write their findings to docs/reconcile/<date>-<topic>.md, never into the store. (Since 2026-07-31 a read-side shape guard drops records lacking learning_key/created_at and mergeCandidates reports the count as skipped — that guard is a backstop, not a licence.)
  • Same pipeline as session-end Phase 3.6.8. This skill uses the identical engine and writer seams as the automatic session-end reconciliation phase — operator experience is consistent, and any fixes to the engine benefit both paths.
  • reconcile.enabled gates the AUTOMATIC session-end phase only. /reconcile is an on-demand command and runs regardless of reconcile.enabled. It still honours rule-expiry-days and confidence-floor from the reconcile config block.

Phase 0: Bootstrap Gate

Read skills/_shared/bootstrap-gate.md and execute the gate check. If the gate is CLOSED, invoke skills/bootstrap/SKILL.md and wait for completion before proceeding. If the gate is OPEN, continue to Phase 1.


Phase 1: Config & Argument Loading

1.1 Read Session Config

Read and parse Session Config per skills/_shared/config-reading.md. Store result as $CONFIG.

1.2 Extract Reconcile Config

Extract the reconcile block from $CONFIG:

# rule-expiry-days defaults to EMPTY (not a number) so the engine falls back to
# its per-type TTL (deriveExpiresAt, default 60d). A numeric override forces flat
# N-day expiry — matching the `null` default of the reconcile: config resolver.
RULE_EXPIRY_DAYS=$(echo "$CONFIG" | jq -r '.reconcile["rule-expiry-days"] // empty')
CONFIDENCE_FLOOR=$(echo "$CONFIG" | jq -r '.reconcile["confidence-floor"] // 0.5')
RECONCILE_MODE=$(echo "$CONFIG"   | jq -r '.reconcile.mode // "warn"')
MIN_RULE_DAYS=$(echo "$CONFIG"    | jq -r '.reconcile["min-rule-days"] // 7')
MIN_INSIGHT_CHARS=$(echo "$CONFIG" | jq -r '.reconcile["min-insight-chars"] // 24')
MAX_PROPOSALS_PER_RUN=$(echo "$CONFIG" | jq -r '.reconcile["max-proposals-per-run"] // 10')

When RULE_EXPIRY_DAYS is empty, pass ruleExpiryDays: undefined to runReconcile so the engine uses its per-type TTL. Defaults when the reconcile block is absent or a field is missing:

  • rule-expiry-days: empty → per-type TTL (deriveExpiresAt, default 60d). Preserves FA2 behaviour; matches the null resolver default.
  • confidence-floor: 0.5
  • mode: warn (enum off | warn)
  • min-rule-days: 7 — floor window (days) applied to a proposed rule's expires-at so a near-dead or already-elapsed natural expiry never produces a born-dead rule (issue #741.1).
  • min-insight-chars: 24 — opt-in minimum insight length gating the eligibility placeholder-insight check (issue #741.2).
  • max-proposals-per-run: 10 — volume brake (issue #900 D); the engine sorts eligible learnings by confidence DESC and proposes at most this many per run.

Note: reconcile.enabled is intentionally NOT checked — this on-demand command always runs.

1.3 Parse Arguments

Check $ARGUMENTS for --dry-run:

DRY_RUN=false
if echo "$ARGUMENTS" | grep -q -- '--dry-run'; then
  DRY_RUN=true
fi

Phase 2: Run the Reconciliation Engine

2.1 Resolve Plugin Root

Resolve $PLUGIN_ROOT per skills/_shared/config-reading.md (the standard resolution chain: $CLAUDE_PLUGIN_ROOT$CODEX_PLUGIN_ROOT$CURSOR_RULES_DIR → common install locations).

2.2 Resolve the Effective Write-Targets

reconcile.targets says WHERE approved rules land. Resolve it BEFORE surfacing the approval AUQ — the operator must never be asked to approve a write to a destination that cannot exist:

import { resolveEffectiveTargets } from '$PLUGIN_ROOT/scripts/lib/reconcile/engine.mjs';

const { targets, baselineRoot, dropped, reason } = resolveEffectiveTargets({
  targets: CONFIG.reconcile?.targets,        // ['repo-local'] | ['baseline'] | both
  baselineRoot: CONFIG['plan-baseline-path'], // already 3-tier-resolved by config.mjs
});
TargetWrites toRoot
repo-local (default)<repoRoot>/.claude/rules/<slug>.mdrepoRoot
baseline (#1099)<baselineRoot>/proposals/<slug>.mdplan-baseline-path, resolved SO_BASELINE_PATH env > owner.yaml paths.baseline-path > committed

baseline is DROPPED (with one stderr WARN, and dropped: ['baseline'] in the return) when the root is unresolvable on all three tiers, is still the committed OVERRIDE-IN-… placeholder, or is not absolute. A dropped target means: do not surface its proposals in the AUQ at all. If targets comes back EMPTY, stop here and report the reason — there is nowhere to write.

Writing to baseline is still advisory and AUQ-gated exactly like repo-local: files land under proposals/ in the baseline checkout, nothing is committed there, and no branch is touched. The operator reviews and commits in that repo himself.

2.3 Invoke runReconcile

import { runReconcileFromSkill } from '$PLUGIN_ROOT/scripts/lib/reconcile/engine.mjs';

const { proposals, rejected, summary, error } = await runReconcileFromSkill({
  repoRoot,             // absolute path from git rev-parse --show-toplevel
  ruleExpiryDays: RULE_EXPIRY_DAYS,   // empty → undefined → engine per-type TTL
  minRuleDays: MIN_RULE_DAYS,         // default 7 — floors a near-dead expires-at
  minInsightChars: MIN_INSIGHT_CHARS, // default 24 — opt-in placeholder-insight length gate
  maxProposalsPerRun: MAX_PROPOSALS_PER_RUN, // default 10 — volume brake (issue #900 D)
  now: new Date(),
  dryRun: DRY_RUN,     // true → engine touches no disk (no idempotency sidecar write)
  // trigger is pinned to 'skill' IN CODE by runReconcileFromSkill (#1201 Part A) —
  // this prose block no longer sets it.
  targets,             // from resolveEffectiveTargets above; recorded when non-empty, omitted otherwise
});

// The engine does NOT apply a confidence floor — it proposes every eligible
// learning and carries each one's `confidence` through. `confidence-floor` is a
// DELIVERY gate: filter proposals here before the sidecar + AUQ (mirrors
// session-end Phase 3.6.8). Use `surfaced` everywhere "proposals" appears below.
const surfaced = proposals.filter((p) => typeof p.confidence === 'number' && p.confidence >= CONFIDENCE_FLOOR);

runReconcile NEVER throws — a top-level error populates result.error instead.

If error is present, surface it to the user and abort:

"Reconcile engine error: <error>. Check .orchestrator/metrics/learnings.jsonl and retry."

2.3 Handle Empty / Zero-Proposal Cases

If summary.totalLearnings === 0:

"No learnings found in .orchestrator/metrics/learnings.jsonl. Run /evolve analyze first to extract session patterns." Exit cleanly.

If summary.eligible === 0 (learnings exist but none are eligible — already proposed, or wrong learning type — eligibility is type/file_paths-based, NOT confidence-based):

"No eligible learnings for rule proposals (total: summary.totalLearnings, already proposed or ineligible type: all). Run more sessions to accumulate evidence." Exit cleanly. Optionally list the rejection reasons from rejected[] (field reason) as an informational table.

If summary.eligible > 0 but surfaced.length === 0 (proposals exist but ALL fall below confidence-floor):

"No proposals above the confidence floor (CONFIDENCE_FLOOR). Lower reconcile.confidence-floor or run more sessions so the underlying learnings accrue confidence." Exit cleanly.

If summary.proposed === 0 but summary.eligible > 0 (emit/render failures consumed all candidates — unusual):

"Engine produced 0 proposals from N eligible learnings. See rejection log." List rejected[].reason and exit.


Phase 3: Dry-Run Branch

Only when DRY_RUN=true.

Print the proposals in a readable table. Do NOT write the sidecar, do NOT render an AUQ, and do NOT write candidates into .orchestrator/runtime/reconcile-candidates.jsonl — that store is mergeCandidates' alone (see Posture Contract). A dry-run write-up belongs in docs/reconcile/.

## Reconcile — Dry Run  (N proposals, M rejected)

| # | Slug | Confidence | Learning Key | Rule Path |
|---|------|-----------|-------------|-----------|
| 1 | <slug> | 0.72 | <learningKey> | .claude/rules/<slug>.md |
| 2 | ...   | ...  | ...          | ...                     |

Rejected (not eligible for proposal):
| Learning Key | Reason |
|-------------|--------|
| <key> | <reason> |

Re-run without --dry-run to enter the approval flow.

Exit after printing. Do not proceed to Phase 4.


Phase 4: Write Pending Sidecar (Normal Mode Only)

Create the runtime proposal sidecar .orchestrator/metrics/reconcile-pending.md as a human-readable record before presenting the AUQ. This sidecar is informational only — it lets the operator see the full proposal set in an editor alongside the AUQ prompt.

# Reconcile Pending — <ISO date>

Generated by `/reconcile` on <timestamp>. N proposals, M rejected.

## Proposals

| # | Slug | Confidence | Learning Key | Rendered Rule Path |
|---|------|-----------|-------------|-------------------|
| 1 | <slug> | 0.72 | <key> | .claude/rules/<slug>.md |
...

## Rejected

| Learning Key | Type | Reason |
|-------------|------|--------|
| <key> | <type> | <reason> |
...

Write via standard file write (not atomic, not lock-protected — this is a disposable sidecar, not a critical artifact).


Phase 5: AUQ Approval Flow (Normal Mode Only)

Present proposals to the operator in batches of 4. Mirror the session-end Phase 3.6.3 / 3.6.8 multiSelect pattern exactly.

For each batch (proposals sliced into groups of 4):

AskUserQuestion({
  questions: [{
    question: "Batch K of N — which rule proposals should be written into .claude/rules/?",
    header: "Regeln",
    options: [
      {
        label: "<slug>.md (confidence: 0.72)",
        description: "From learning <learningKey>. Becomes a file under .claude/rules/ — where this repo keeps its rules. Text: <first 100 chars of rendered content>"
      },
      ...up to 4 options per batch...
      {
        label: "Skip all in this batch",
        description: "Decline all proposals in this batch — they are archived to the rejected log."
      }
    ],
    multiSelect: true
  }]
})

Collect responses across all batches:

  • Selected options (excluding "Skip all") → approved[]
  • Unselected options + "Skip all" batches → rejected_by_operator[]

Codex CLI fallback: AskUserQuestion is unavailable in subagents and on Codex CLI (AUQ-004). In those contexts, present proposals as a numbered Markdown list and ask the operator to reply with the numbers they wish to approve.


Phase 6: Write Approved Rules

6.1 Invoke writeApprovedRules

import { writeApprovedRules } from '$PLUGIN_ROOT/scripts/lib/reconcile/writer.mjs';

const { written, archived, errors } = await writeApprovedRules({
  approved: approved,             // proposals the operator approved
  rejected: rejected_by_operator, // proposals the operator declined
  repoRoot,
  baselineRoot,                   // from Phase 2.2; omit/undefined ⇒ baseline is a no-op
  targets,                        // from Phase 2.2; omitted ⇒ ['repo-local']
  sessionId: currentSessionId,    // informational; from STATE.md or 'manual'
});

writeApprovedRules NEVER throws — per-item failures are collected in errors[].

written is a FILE count, not a proposal count: one proposal approved with both targets in effect writes two files and counts 2, while stamping the idempotency sidecar exactly once. A baseline root that does not exist on disk (the fresh-clone / CI case) skips that target with an errors[] entry — it is NEVER created, because a typo'd path that silently mints a directory tree looks exactly like a successful write.

6.2 Handle Errors

If errors.length > 0, surface each error to the operator:

"Warning: N rule(s) failed to write: <error list>. Successfully written: written. Archived: archived."

Log each error but do NOT abort — partial success is acceptable.

6.3 Report

## Reconcile Complete

- Written:  <written> rule file(s) to .claude/rules/
- Archived: <archived> declined proposal(s) to .orchestrator/reconcile.rejected.log
- Errors:   <errors.length> (see warnings above, if any)

New rules take effect immediately — they are loaded by the wave-executor's rule-loader
on the next wave dispatch.

If written === 0 and approved.length === 0:

"No proposals approved. No rules written."


Consolidating and Dropping Generated Rules (merge contract)

.claude/rules/ grows one file per approved learning, so it accumulates. This repo consolidated 43 generated files (112,443 B, 46.2 % frontmatter+provenance overhead) into 8 thematic files plus 10 drops on 2026-09-06. Both operations are safe ONLY under the contract below — the full authoring spec is docs/rule-authoring.md § "Consolidated rules: N provenance pairs in ONE file". The three facts that decide whether a consolidation survives the next /reconcile:

  • A target file may carry N provenance bullet PAIRS. Frontmatter learning-key: is a scalar, so at most one marker fits there; the other N−1 live in the body as - learning-key: `…` + - learning-id: `…` bullets, which engine.mjs reads via BODY_LEARNING_KEY_RE / BODY_LEARNING_ID_RE. One pair per absorbed learning — a missing pair regenerates that learning as a standalone file on the next run.
  • A merged file's expires-at is the EARLIEST of its parts, never the latest: it must not outlive its shortest-lived content.
  • A dropped learning must be STAMPED before deletion, or it regenerates. rm .claude/rules/<slug>.md alone leaves isProcessed() false and no on-disk marker, so the engine re-proposes it. Stamp it terminal first with markCandidateProcessed({ learningKey, outcome: 'rejected', fallbackSlug, repoRoot }) from scripts/lib/reconcile/idempotency.mjs — the ONLY sanctioned writer of .orchestrator/runtime/reconcile-candidates.jsonl (never append to that file by hand; the read-side shape guard drops foreign records and mergeCandidates rewrites the store in full).

Verify a consolidation with a dry run, not by eye: alreadyMaterialized must equal absorbed + dropped. If it equals only the absorbed count, the drops were not stamped and the next run will resurrect them. Do the whole operation while reconcile.enabled: false in Session Config, so nothing regenerates underneath you mid-edit.

Critical Rules

  • NEVER call writeApprovedRules before the operator has confirmed via AUQ — this is the only write-protection gate for .claude/rules/.
  • NEVER pass dryRun: false to runReconcile and then skip the AUQ — the idempotency sidecar is written during the engine run; writing rules without AUQ confirmation would create an inconsistency between the sidecar and the actual rule files.
  • ALWAYS surface errors[] from writeApprovedRules — per-item isolation must not silently swallow failures.
  • ALWAYS present proposals in batches of ≤4 via AUQ multiSelect — mirrors session-end 3.6.3 / 3.6.8 and keeps the operator prompt readable.
  • ALWAYS honour confidence-floor, rule-expiry-days, min-rule-days, min-insight-chars, and max-proposals-per-run from Session Config reconcile block — the engine reads these, but the skill must pass them explicitly.

Anti-Patterns

  • DO NOT write any file to .claude/rules/ without AUQ operator confirmation.
  • DO NOT check reconcile.enabled — that flag gates the automatic session-end phase, not this on-demand command.
  • DO NOT emit or approve a rule with alwaysApply: true — the engine structurally prevents it, but the reviewer should reject any proposal that would produce an always-on rule.
  • DO NOT skip the dry-run branch when --dry-run is passed — the entire AUQ + write flow must be bypassed.
  • DO NOT treat runReconcile failures as fatal — check the error field and surface it, then exit cleanly.

Signals

GitHub stars
50
Forks
7
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
reconcile-kanevry
Source
github.com/kanevry/session-orchestrator