Local Self-Evolution Loop (LSEL) curator — the CLUSTER + drain engine for the GOOS-local PROPOSE→APPLY seam closure (SPEC-LSEL-LOCAL-EVOLUTION-001). Companion-offset drain of .moai/lessons-inbox.jsonl with a drain-side severity filter that drops the ~65% Bash-timeout/sandbox noise, event_key clustering with a frequency gate, and a Generative-Agents-style 1-10 importance score. Candidates stage at .moai/state/lsel/clusters.json. M1 = drain only (NO PROPOSE, NO APPLY, NO memory/ writes).
52
58%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./.claude/skills/hns-lsel-curator/SKILL.mdNamespace:
hns-lsel-*is user-owned dogfood (CLAUDE.local.md §24). This skill is NOT mirrored intointernal/template/templates/— it lives only in this repo. Graduation tomoai-lsel-*+ 16-language distribution is a separate SPEC (out of scope per spec.md §G).M1 scope: drain + cluster + stage candidates. NO APPROVE, NO APPLY (M3). M2 scope: drain + cluster + PROPOSE shadow (no APPROVE, no APPLY). The PROPOSE stage emits shadow proposals + self-critiques; APPROVE/APPLY land in M3 via the fresh
hns-lsel-applierpath. M2 does NOT write tomemory/— the firstfeedback_*.mdtopic file is an M3+ deliverable after APPROVE.
The MoAI-ADK repo accumulates tool-failure stubs in .moai/lessons-inbox.jsonl (624 stubs at
M1 start, re-measured — a moving target). The constitution names the orchestrator as the drain
actor, but until this skill there was zero mechanical drain code — the drain existed only as
a doctrine paragraph (moai-constitution.md:147). This skill closes that gap in user-owned
surfaces, without touching the frozen Go applier (internal/harness/applier.go:22 —
its write-flag stays false; REQ-LSEL-003: bypass, never unfreeze).
The drain is split into a mechanical core (drain.sh, deterministic, testable) and a
model-mediated layer (this SKILL.md + your judgment, invoked for M2+ importance refinement
and proposal drafting).
drain.shdrain.sh is a portable bash + jq script that lives next to this SKILL.md. It performs the
deterministic half of the drain:
drain.sh --inbox <path-to-lessons-inbox.jsonl> --state-dir <path-to-lsel-state>Pipeline (REQ-LSEL-009 + AC-LSEL-009 / AC-LSEL-010):
<state-dir>/drain-offset.json (seed {"offset":0} if absent).
The inbox is append-only and is NEVER mutated; the offset marks consumed stubs
(SPEC-HARNESS-RATCHET-REWIRE-001 D3 companion-offset pattern).tail -n +<offset+1>).tool_failure:Bash:UnknownFailure — the opaque ~65% timeout/sandbox bucket (the dominant
noise share; report §2).tool_failure:Bash:SandboxViolation — environment constraint, not a code defect.*:TimeoutError (Bash + MCP timeouts).
The filter is drain-side because internal/hook/failure_observer.go (the inbox writer) is
OUTSIDE the six loop-writable surfaces (plan.md §F.1 [DECISION RESOLVED]), so the loop cannot
edit the writer — it filters on read instead.event_key with frequency count, first/last seen, and up to 3 sample summaries.frequency < 2 (single-occurrence noise per the
constitution Lessons Protocol drain paragraph).importance = min(10, frequency) (frequency as proxy; the model augments this in M2+ with a
severity hint and retrieval-weighted judgment).<state-dir>/clusters.json; advance the companion offset.clusters.json schema{
"drained_at": "2026-08-04T08:41:00Z",
"offset_before": 0,
"offset_after": 624,
"total_read": 624,
"noise_discarded": 533,
"singletons_discarded": 4,
"candidates": [
{
"event_key": "tool_failure:Agent:UnknownFailure",
"frequency": 41,
"first_seen": "...",
"last_seen": "...",
"sample_summaries": ["...", "...", "..."],
"source": "tool:Agent",
"importance": 10
}
]
}If the inbox has not grown past the offset, drain.sh writes an empty-candidate clusters.json
and leaves the offset unchanged. Not a failure (acceptance.md §E edge case).
drain.sh produces the deterministic candidate set. When this skill is invoked for a real
curation pass (M2+), your job on top of the mechanical output is:
clusters.json and rank candidates by importance then frequency.Bash:ExitError cluster points at a real command-shape defect (high signal); a recurring
Agent:ContextCancelled cluster may be session-teardown noise (lower signal). Record the
rationale in the candidate's prose when you draft the M2 proposal — do NOT rewrite
clusters.json (it is the mechanical artifact; your augmentation lives in the proposal).memory/ in M1. Candidates stage in clusters.json only. The first
feedback_*.md topic file is produced by the M2 PROPOSE stage after retrieval-before-propose
and self-critique (REQ-LSEL-010).hns-lsel-applier) is M3..claude/rules/moai/**, CLAUDE.md,
internal/template/templates/**, retained agents, moai-* skills, and the frozen Go
applier / curator_dispatch.go are all byte-for-byte untouched (REQ-LSEL-001 / §B.3)..moai/config/sections/ file — loop state lives under .moai/state/lsel/
(a new section file would be wiped on moai update; plan.md §B.4 / AP-LSEL-005).session_drain.sh)The drain's original "schedule" was a session-scoped /loop recipe that died with its
owning session (2026-08-04) and executed nothing even while alive — the inbox stalled
for 3 weeks with nobody notified (SPEC-LSEL-DRAIN-STALL-001 §B). The trigger is now
mechanical:
ALL drains route through session_drain.sh (next to this SKILL.md), never
drain.sh directly. The wrapper adds what the frozen core deliberately lacks: an
exclusive drain lock (contention = safe no-op), an unconditional archive of any
existing clusters.json to clusters-history/ BEFORE any overwrite (a direct
drain.sh call bypasses archiving and can silently discard staged candidates —
drain.sh overwrites clusters.json on both the drain path and the no-op path), a
one-line status, and fail-open (any internal error degrades to a stderr notice and
exit 0 — the hook never blocks session start).
session_drain.sh [--inbox <path>] [--state-dir <dir>] # defaults: live pathsPROPOSE reads the archived copies in .moai/state/lsel/clusters-history/ (newest
first), NOT the live clusters.json — under per-session-start drains the live file is
ephemeral: the next no-op session start overwrites it with candidates: []. The
wrapper's archive is what survives.
Local wiring is a maintainer-machine deliverable (applied in M2, NOT carried by
the PR — a tracked .claude/settings.json entry would be wiped by every
moai update, so tracked wiring is affirmatively wrong): add BOTH session_drain.sh
and backlog_check.sh to .claude/settings.local.json .hooks.SessionStart with an
explicit "timeout": 30 each (the measured full-backlog drain is <1s for a 1.1MB
inbox; 30s matches the live SessionStart hook precedent). Wiring verification:
jq '.hooks.SessionStart' .claude/settings.local.json.
Removed dead anchors: the former CLAUDE.local.md section-28 anchor references in
backlog_check.sh (header comment + reminder body — both occurrences) were removed in
SPEC-LSEL-DRAIN-STALL-001 M1; the operating instructions now live in this section,
mirrored into the restored CLAUDE.local.md LSEL section (M2 local deliverable).
# 1. The drain mechanics + the wrapper (fixture-based characterization tests —
# AC-LSEL-009/010 and AC-LDS-001..006 + the mutant probe):
.claude/skills/hns-lsel-curator/drain_test.sh
.claude/skills/hns-lsel-curator/session_drain_test.sh
# 2. A real drain of the live backlog — VIA THE WRAPPER (all drains are
# wrapper-mediated; re-measure first, the inbox is a moving target). Capture
# BEFORE the drain, then judge the ARCHIVED copy: a later no-op session-start
# drain overwrites the live clusters.json with candidates: [], but the wrapper
# archives unconditionally before any overwrite, so the bulk-drain result
# survives in clusters-history/.
OFFSET_BEFORE=$(jq -r .offset .moai/state/lsel/drain-offset.json)
LIVE_COUNT=$(wc -l < .moai/lessons-inbox.jsonl | tr -d ' ')
.claude/skills/hns-lsel-curator/session_drain.sh --inbox .moai/lessons-inbox.jsonl --state-dir .moai/state/lsel
ARCHIVE=$(ls -t .moai/state/lsel/clusters-history/clusters-*.json | head -1)
jq --argjson n "$LIVE_COUNT" --argjson b "$OFFSET_BEFORE" \
'(.offset_after == $n) and ((.candidates // []) | length >= 1) and (.total_read == ($n - $b))' "$ARCHIVE"
# must print: true (offset==live AND candidates>=1 AND self-consistent — the
# AC-LDS-010 predicate; the session_drain_test.sh mutant probe proves it rejects
# an offset-only-advance fake)
# 3. M1 invariant — zero memory/ writes from the drain:
find memory -newer <drain-start-timestamp> -name 'feedback_*' 2>/dev/null | wc -l # must be 0drain_test.sh (next to this SKILL.md) is the TDD RED→GREEN harness. It builds a synthetic
inbox with known noise + signal stubs, runs drain.sh, and asserts the drain semantics:
noise excluded pre-cluster, signal clustered with correct frequencies, singletons discarded,
offset advanced, candidates emitted, zero memory/ writes, idempotent re-drain. Run it after
any edit to drain.sh. session_drain_test.sh (same directory) is the wrapper harness —
five paths (drain / lock contention / archive-before-overwrite / no-op / fail-open) plus the
mutant probe. Run it after any edit to session_drain.sh.
.moai/specs/SPEC-LSEL-LOCAL-EVOLUTION-001/{spec,plan,acceptance,progress}.md.moai/reports/moai-local-self-evolution-design-20260804.html
§6 stage 2 (CLUSTER), §10 P1, §11 mustFix B#1/B#3.internal/harness/applier.go:22
(the write-flag, kept false), internal/harness/curator_dispatch.go..claude/rules/moai/core/moai-constitution.md:147.internal/template/split_namespace_test.go,
internal/template/internal_content_leak_test.go (extended in M2 — AC-LSEL-006).The PROPOSE stage consumes the candidate clusters archived in
.moai/state/lsel/clusters-history/ (newest archive — the live clusters.json is
ephemeral under per-session-start drains: the next no-op drain overwrites it with
candidates: []; SPEC-LSEL-DRAIN-STALL-001 REQ-LDS-010) and
emits shadow proposals — one per candidate worth acting on — at
.moai/state/lsel/proposals/<proposal-id>/. M2 proposals are SHADOW only: no APPROVE, no APPLY.
APPROVE/APPLY land in M3 via the fresh hns-lsel-applier path (NOT via the dead
moai-harness-learner Tier-4 flow — see "Tier-4 finding" below).
BEFORE drafting a proposal, retrieve relevant feedback_*.md topic files from
~/.claude/projects/<hash>/memory/. The retrieval grounds the proposal in prior
lessons (Reflexion-style) and is evidenced in the proposal's retrieval_evidence
block. A proposal without retrieval evidence is malformed and MUST NOT be emitted.
Each proposal lives at .moai/state/lsel/proposals/<id>/ and contains exactly:
| File | Purpose |
|---|---|
proposal.md | YAML-frontmatter payload + prose body |
diff.patch | The proposed edit (unified diff; NOT applied in M2) |
self-critique.md | Model-performed critique against frozen doctrine |
proposal.md YAML frontmatter carries the full schema (8 required keys):
---
proposal_id: lsel-001
target_surface: <one of the 6 evolvable surfaces, spec.md §B.3>
rationale: |
<what + why>
WHY-not-just-WHAT: |
<the reasoning, not just the change — catches "what" proposals that skip the "why">
prediction: <a FALSIFIABLE expected effect — the verify_command must be able to falsify it>
verify_command: <a runnable command that, if green, confirms the prediction>
blast_radius: <which surfaces the diff touches; used by the CSA forced-gate match>
memory_type: semantic|procedural|episodic # CoALA taxonomy
retrieval_evidence:
- <path to a feedback_*.md retrieved before drafting>
status: blocked # blocked | ready — blocked if self-critique has an UNRESOLVED objection
---self-critique.md is model-performed (NOT a mechanical doctrine checker — report §13 caveat 3:
the model can rationalize; the frozen allowlist + /moai gate are the real safety floor). It
lists objections against frozen doctrine; each objection is marked RESOLVED or UNRESOLVED. A
proposal with ANY UNRESOLVED objection is status: blocked and MUST NOT proceed to APPROVE.
A proposal that never converges stays blocked; the curator returns a blocker report and the
orchestrator surfaces it (acceptance.md §E edge case — not a ship-blocker for M2; it proves
the gate fires).
Finding (verified 2026-08-04 via tier4_firing_test.sh): the moai-harness-learner Tier-4
synchronous-user-question flow is DEAD at the production invocation layer. The CLI (moai harness apply)
prints a stub string and never invokes the learner skill; CuratorDispatch has 0 production
callers (the audit's cautionary precedent); the frozen applier's write-flag (false
at internal/harness/applier.go:22) is the apply dead-switch; and NO mechanical trigger causes the orchestrator to surface a Tier-4
proposal (the audit's exact failure mode, report §11 mustFix B#1).
Per acceptance.md §E edge case, M2 does NOT wire the PROPOSE→APPROVE handoff to depend on the
Tier-4 flow. APPROVE routes via the M3 fresh path (hns-lsel-applier + decision.json with a
synchronous-approval marker). M2 emits shadow proposals only. This finding is recorded in
tier4_firing_test.sh and cited in the M2 wiring commit.
The APPROVE stage (M3, hns-lsel-applier) forces a synchronous user-question gate
(orchestrator-run) — regardless of proposer confidence — for any proposal whose blast
radius touches one of the SIX CSA
forced-gate categories:
CLAUDE.local.md.@MX:ANCHOR functions with fan_in ≥ 3 callers.BASH_SUBCOMMAND_SOFT_CAP compound
commands (coding-standards.md § Bash Risk-Amplifier Doctrine).permissions.allow additions — explicit security-exception band; per-line synchronous
approval (every added allow entry is its own forced gate)..claude/lsel/frozen-allowlist.json, (ii) an applier
or curator skill body (hns-lsel-applier/, hns-lsel-curator/), (iii) the apply hook
script (lsel-apply.sh and wrappers), (iv) the settings.local.json hook-registration
subblock.Bother-cost-exemption: forced gates are bother-cost-exempt — the bother-cost gating rule applies ONLY to routine-tier proposals. A forced-gate proposal always triggers a synchronous user-question gate (orchestrator-run) regardless of bother-cost state.
Mechanical enforcement (D3): the applier (hns-lsel-applier driving lsel-apply.sh,
M3) intercepts every proposal matching the four execution-meta categories and REFUSES to
write unless the proposal's decision.json carries an explicit synchronous-approval marker
(an approval artifact produced by the orchestrator's synchronous user-question gate).
A match with no marker aborts the apply,
appends a rejection row to .moai/logs/lsel-reject.log naming the matched category, and
writes nothing. Proposals matching none of the four categories proceed through the routine
bother-cost path. This mechanical interception is what makes the self-amending-handcuffs
defense defensible without resting on the regex paradox alone.
csa_refusal_test.sh (next to this SKILL.md) is the fixture test for the refusal rule.
The periodic consolidation pass that prevents un-refined accumulation — the dominant failure mode the design report §10 P4 names: "no consolidation / decay / pruning → wrong-lesson retrieval". Without REFLECTION, concrete topic files pile up and retrieval surfaces stale concrete incidents instead of the principle they collectively support.
REFLECTION fires when the accumulated importance of concrete feedback_*.md
topic files clears the threshold (default ~150), NOT on a monthly cron. This is
the Vectorize 4-lever model (importance-gate / merge / decay / evict) the design
report §10 P4 cites: importance is assigned write-time, and the reflection
threshold is an accumulation signal, not a calendar one. A single-topic cohort
below the threshold is a clean no-op (acceptance.md §E edge case).
reflect.shreflect.sh --memory-dir <m> [--threshold 150] [--min-topics 3]:
feedback_*.md topic files (maxdepth 1 — never the
_archive/ cold tier).importance. If count < min-topics OR sum < threshold → clean no-op (exit 0).feedback_*_principle_*.md carrying:
memory_type label (CoALA taxonomy — semantic for a feedback principle;
procedural would route to a hns-* skill body instead).source_count + synthesized_at for the audit trail.memory/_archive/ (cold tier) — NEVER deleted
(report §10 P4: "축출 ≠ 보관 — 보관은 성능용, 하드 삭제는 규정 준수용; MoAI의
'삭제 말고 보관' 규칙이 옳음이 입증된다" — archive preserves the audit trail).The originals relocate to _archive/, so the active recall set (the
memory/ directory the recall layer scans first) holds the synthesized
principle, NOT the stale concrete originals. A retrieval probe for a related cue
returns the principle ranked ABOVE the archived originals — this is the
decay-weighted retrieval AC-LSEL-016 clause requires. The principle's
description is crafted to match the shared cue; the archived originals stay
discoverable (cold tier) but no longer dominate the top of the recall set.
reflect.sh performs the mechanical synthesis (deterministic). When this skill
runs a real reflection pass, your job on top is:
hns-* skill body), stamp memory_type: procedural
and route the synthesis to the skill rather than leaving it as a feedback_*._archive/ are the audit trail.
If the hot tier (active feedback_*.md) approaches the 50-file cap, prefer
archiving more concrete topics over deleting them (moai-memory.md § Memory
Hygiene).# M4 REFLECTION characterization test (AC-LSEL-016) — hermetic temp memory dir:
.claude/skills/hns-lsel-curator/reflect_test.sh
# cold-tier growth vs hot-tier (post-M4 audit, acceptance.md §H):
ls memory/_archive/ | wc -l # archived originals
ls memory/feedback_*.md | wc -l # active hot tier2213871
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.