Skip to content

feat(skill-optimizer): SkillOpt-flavored offline training loop#64

Merged
rohitg00 merged 3 commits into
mainfrom
feat/skill-optimizer
Jun 3, 2026
Merged

feat(skill-optimizer): SkillOpt-flavored offline training loop#64
rohitg00 merged 3 commits into
mainfrom
feat/skill-optimizer

Conversation

@rohitg00

@rohitg00 rohitg00 commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Implements the bridge sketched in the SkillOpt analysis — treat learn-rule outputs as training trajectories, run a budget-capped offline optimization over each skill's SKILL.md, gate by validation set of past corrections.

What it does

A new /skill-optimize <slug> command runs a 6-stage pipeline borrowed from Microsoft SkillOpt's ReflACT loop, adapted to pro-workflow's existing SQLite + learnings tables:

rollout   -> existing learn-rule rows for the slug
reflect   -> optimizer LLM proposes bounded add/delete/replace patches
aggregate -> vote-merge patches across minibatches
select    -> clip by LR budget (default: 3 adds, 2 deletes, 3 replaces per step)
update    -> apply patches deterministically to a candidate
evaluate  -> evaluator LLM scores candidate against frozen validation holdout
gate      -> accept candidate only when delta >= acceptThreshold

Rejected candidates feed a rejection buffer that biases the next reflect step. At epoch boundaries a slow-update consolidation rewrites the best skill; the rewrite itself must pass the gate.

Files

src/optimizer/
  types.ts        Patch, Trajectory, ValidationItem, TrainerConfig, ...
  hash.ts         16-char sha256 skill hash
  apply.ts        deterministic patch application (add/delete/replace)
  aggregate.ts    vote-merge with op ordering
  clip.ts         LR-budget enforcement
  llm.ts          provider dispatch (anthropic/openai/openrouter/fireworks)
  reflect.ts      reflect-step LLM call + JSON parse
  validate.ts     gate LLM call + weighted scoring
  slow.ts         slow-update consolidation
  store.ts        DB layer for the 5 new tables + train/val split helper
  trainer.ts      orchestrator (epoch loop, kill switch, budget cap)
  __tests__/      9 node:test cases for the pure layer

scripts/optimize-skill.js     CLI entry, --json mode, kill-switch aware
skills/skill-optimizer/SKILL.md
commands/skill-optimize.md
src/db/schema.sql             +5 tables (runs, candidates, patches, validation, rejections)

Configuration

Flag Default
--epochs N 3 outer loop
--batch-size N 8 trajectories per minibatch
--minibatches N 2 per epoch
--holdout N 6 validation reserved (capped at 25% of trajectories)
--budget-usd X 0.50 hard cap; abort on exhaust
--optimizer-model M claude-sonnet-4-6 reflect + slow
--evaluator-model M claude-haiku-4-5-20251001 gate (cheaper)
--max-adds / --max-deletes / --max-replaces 3 / 2 / 3 per-step LR budget
--accept-threshold X 0.0 minimum delta
--max-skill-tokens N 2000 candidate length cap
--slow-every N 2 epochs between consolidation

Kill switch: touch ~/.pro-workflow/STOP.

Provenance

Inspired by Microsoft SkillOpt (arXiv:2605.23904). The 6-stage pipeline, LR budget, rejection buffer, and slow / meta update mechanics are adapted to pro-workflow's data plane. No SkillOpt code reused; this is an independent port of the concepts in TypeScript on top of better-sqlite3.

Test plan

  • Pure layer (apply / aggregate / clip): 9 node:test cases via npm test
  • Build: npm run build clean
  • CLI no-arg: prints usage and exits 1
  • CLI bad slug: prints "SKILL.md not found" and exits 1
  • CLI low trajectories: errors with only N trajectories; need >= 8
  • Reviewer: end-to-end with ANTHROPIC_API_KEY against a real skill (requires API spend; defaults cap at $0.50)
  • Reviewer: verify SQLite schema migrates cleanly on existing ~/.pro-workflow/data.db

Out of scope

  • Meta-update (epoch-of-epochs deeper consolidation): config flag exists, hook is wired but the implementation defers to a follow-up
  • Multi-provider mixing within a single run: optimizer and evaluator can already be different models from the same provider; cross-provider works but is untested
  • A real per-skill validation seed UI: validation rows are currently auto-derived from the longest-applied trajectories on first run

Summary by CodeRabbit

  • New Features

    • Added /skill-optimize command and CLI for offline, budget‑capped skill training with optional JSON output, kill‑switch, validation holdout, slow‑update consolidation, and automatic overwrite of a skill when an improved candidate is found.
  • Chores

    • Version bumped to 3.4.0 and added a npm test script for optimizer tests.
  • Tests

    • Expanded optimizer test coverage for patching, aggregation, clipping, stamping, and related behaviors.

Adds a new skill, slash command, and Node trainer that treats accumulated learn-rule rows as training trajectories and runs a budget-capped offline optimization over an existing SKILL.md.

Mirrors Microsoft SkillOpt's ReflACT pipeline adapted to pro-workflow's SQLite plane: rollout pulls recent learnings, reflect calls an optimizer LLM for bounded add/delete/replace patches, aggregate vote-merges across minibatches, clip enforces an LR budget, apply patches deterministically, evaluator LLM scores the candidate against a frozen held-out validation set built from the same trajectories, and the gate accepts only when delta >= accept threshold.

Rejected candidates feed a buffer that biases the next reflect step; epoch boundaries trigger a slow-update consolidation pass that must itself pass the gate. Kill switch at ~/.pro-workflow/STOP halts between steps. Provider dispatch supports anthropic / openai / openrouter / fireworks, matching the existing llm-council pattern.

Schema: 5 new tables (optimization_runs, optimization_candidates, optimization_patches, optimization_validation, optimization_rejections). CLI: scripts/optimize-skill.js with --json mode. Skill: skills/skill-optimizer/SKILL.md. Command: /skill-optimize.

Pure layer (apply / aggregate / clip / hash) covered by 9 node:test cases. LLM-driven layer (reflect / validate / slow / trainer) wires the dispatch but is tested live against a real provider key on invocation.

Version bump 3.3.0 -> 3.4.0 for the new top-level skill + command + schema surface.
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2873c81d-c2ff-4834-b060-884542c9fbc2

📥 Commits

Reviewing files that changed from the base of the PR and between 41e5b50 and 84bd527.

📒 Files selected for processing (2)
  • src/optimizer/__tests__/apply.test.ts
  • src/optimizer/trainer.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/optimizer/tests/apply.test.ts
  • src/optimizer/trainer.ts

📝 Walkthrough

Walkthrough

This PR introduces a complete offline skill optimizer that generates, aggregates, clips, applies, and evaluates LLM-proposed patches against SKILL.md; it persists runs, candidates, patches, validation items, and rejections in SQLite, enforces LR/USD budgets and a kill-switch, and provides a CLI, tests, and documentation.

Changes

Offline Skill Optimizer System

Layer / File(s) Summary
Type definitions and database schema
src/optimizer/types.ts, src/db/schema.sql
Foundational TypeScript types (Patch, Trajectory, ValidationItem, TrainerConfig, etc.) and SQLite tables (optimization_runs, optimization_candidates, optimization_patches, optimization_validation, optimization_rejections) supporting the optimizer lifecycle and persistence.
Patch application, aggregation, clipping, hashing, and tests
src/optimizer/apply.ts, src/optimizer/aggregate.ts, src/optimizer/clip.ts, src/optimizer/hash.ts, src/optimizer/__tests__/apply.test.ts
Core pipeline for manipulating patches: applyPatches handles add/delete/replace operations with anchor validation and skip reasons, aggregatePatches merges duplicate proposals and reports conflicts/duplicates, clipByLR enforces per-op quotas, skillHash computes deterministic short hashes, and unit tests cover edge cases and regressions.
LLM integration and patch reflection
src/optimizer/llm.ts, src/optimizer/reflect.ts, src/optimizer/parse.ts
Provider-agnostic callLLM with provider routing, token pricing, timeout and response parsing; reflect builds strict-JSON prompts from trajectories/rejections and returns parsed patches and reasoning.
Skill validation and evaluation
src/optimizer/validate.ts
LLM-based validator that scores a candidate skill against frozen validation items, parses per-item outcomes, and computes a weighted aggregated score.
Slow update consolidation
src/optimizer/slow.ts
Consolidation routine that asks an LLM to rewrite the best skill using accepted diffs and trajectories, parses the returned skill, and reports if it changed plus cost and reasoning.
SQLite-backed optimizer store
src/optimizer/store.ts
Persistence layer using better-sqlite3 prepared statements for runs, candidates, patches, rejections, and validation; includes deterministic train/validation splitting helper and transactional patch/rejection writes.
Training loop orchestrator
src/optimizer/trainer.ts
Main train() entry point implementing baseline evaluation, epoch/minibatch loop with kill-switch and USD budget guards, reflect→aggregate→clip→apply→evaluate flow, slow-update scheduling, persistence, and conditional on-disk SKILL.md stamping.
CLI script and package setup
scripts/optimize-skill.js, package.json
Node CLI that parses flags (epochs, budgets, providers, token caps, JSON output), resolves SKILL.md and compiled trainer, constructs trainer config (including kill-switch path), invokes train(), and adds package version bump to 3.4.0 plus a test script.
Command documentation and skill README
commands/skill-optimize.md, skills/skill-optimizer/SKILL.md
User docs describing /skill-optimize usage, required preconditions, flags, stop conditions, persistence outputs, and the full SkillOpt-inspired SKILL.md workflow and defaults.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLIScript
    participant Trainer
    participant Store
    participant Reflect
    participant Provider
    participant Validate
    User->>CLIScript: /skill-optimize skill-slug --epochs 3 ...
    CLIScript->>CLIScript: parse flags, resolve SKILL.md
    CLIScript->>Trainer: train(skillSlug, skillPath, config)
    Trainer->>Store: createRun, collectTrajectories
    loop each epoch
        Trainer->>Trainer: shuffle minibatches
        loop each minibatch
            Trainer->>Reflect: reflect(skill, trajectories, rejections)
            Reflect->>Provider: callLLM(system, user)
            Provider-->>Reflect: { text, tokens, cost }
            Reflect-->>Trainer: { patches, reasoning, costUsd }
            Trainer->>Trainer: aggregatePatches, clipByLR
            Trainer->>Trainer: applyPatches → candidate
            Trainer->>Validate: validateSkill(candidate, items)
            Validate->>Provider: callLLM(scoring prompt)
            Provider-->>Validate: outcomes JSON
            Validate-->>Trainer: { score, passed, costUsd }
            alt score improves
                Trainer->>Store: saveCandidate, recordPatches
                Trainer->>Trainer: update bestSkill, bestScore
            else score worsens
                Trainer->>Store: rejectPatches, saveRejection
            end
        end
        alt slow-update interval
            Trainer->>Provider: runSlowUpdate(bestSkill, diffs)
            Provider-->>Trainer: { skill, changed, cost }
            Trainer->>Validate: validateSkill(slowSkill)
            alt improves
                Trainer->>Trainer: accept slow-updated skill
            end
        end
    end
    Trainer->>Trainer: writeSkillFile + hash stamp
    Trainer->>Store: endRun
    Trainer-->>CLIScript: TrainerOutcome
    CLIScript-->>User: JSON or summary
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 I hop through diffs with tiny care,
LLM whispers ideas in the air,
Aggregate votes and clip by budget tight,
Validate by moon and gate by light,
Only the best skill earns the stamp tonight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(skill-optimizer): SkillOpt-flavored offline training loop' clearly and specifically summarizes the main change—adding a SkillOpt-inspired offline skill-optimization pipeline with an LLM-driven multi-stage training loop.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/skill-optimizer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (4)
src/optimizer/reflect.ts (1)

99-101: ⚡ Quick win

Duplicated fence-strip + safe-JSON-parse logic across modules.

The ^```(?:json)? / trailing-fence strip plus try/catch JSON.parse pattern is repeated here, in validate.ts (Line 55) and slow.ts (Line 60). Extracting a small shared stripFencesAndParse helper would keep parsing behavior consistent if the model output format changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/optimizer/reflect.ts` around lines 99 - 101, Duplicate fence-stripping
and JSON-parse logic exists in stripFences (function name) and similar code in
validate.ts and slow.ts; extract a single helper (e.g., stripFencesAndParse)
that 1) strips leading ``` or ```json and trailing ``` using the existing regex,
2) safely parses JSON in a try/catch and returns either the parsed object or a
controlled error/undefined, and 3) replace the inline logic in stripFences,
validate.ts, and slow.ts to call this new helper so all modules share identical
parsing behavior.
skills/skill-optimizer/SKILL.md (1)

24-24: 💤 Low value

Add a language to the fenced code block (MD040).

markdownlint flags this fence; use ```text to satisfy the linter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/skill-optimizer/SKILL.md` at line 24, The fenced code block in
SKILL.md is missing a language specifier which triggers markdownlint MD040;
update the triple-backtick fence on the block at the shown location to include a
language (for example use ```text) so the fence becomes a labeled code block and
satisfies the linter.
commands/skill-optimize.md (1)

11-11: 💤 Low value

Add a language to fenced code blocks (MD040).

markdownlint flags these fences as missing a language. Use ```text (or ```bash) to satisfy the linter.

Also applies to: 31-31

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@commands/skill-optimize.md` at line 11, The markdown file contains fenced
code blocks that lack a specified language (MD040); update each triple-backtick
fence in commands/skill-optimize.md to include an explicit language token (e.g.,
change ``` to ```text or ```bash) for the blocks referenced (the fences around
the snippets at the reported locations), ensuring all code fences across the
file include a language to satisfy markdownlint.
scripts/optimize-skill.js (1)

38-39: 💤 Low value

Numeric flags aren't validated; bad input becomes NaN.

parseInt/parseFloat on a missing or non-numeric value yields NaN, which silently propagates into config (e.g. epochs, budgetUsd) and produces confusing downstream behavior rather than a clear error. A small guard would fail fast.

♻️ Optional guard
-    if (intKeys.has(key)) out[key] = parseInt(v, 10);
-    else if (floatKeys.has(key)) out[key] = parseFloat(v);
-    else out[key] = v;
+    if (intKeys.has(key)) {
+      const n = parseInt(v, 10);
+      if (Number.isNaN(n)) { process.stderr.write(`Invalid integer for --${a.slice(2)}: ${v}\n`); process.exit(1); }
+      out[key] = n;
+    } else if (floatKeys.has(key)) {
+      const n = parseFloat(v);
+      if (Number.isNaN(n)) { process.stderr.write(`Invalid number for --${a.slice(2)}: ${v}\n`); process.exit(1); }
+      out[key] = n;
+    } else out[key] = v;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/optimize-skill.js` around lines 38 - 39, The current branch that sets
numeric config values (using intKeys, floatKeys, out, key, v) can insert NaN
when v is missing or non-numeric; add a guard before parsing: if v is null/empty
or Number(v) is NaN or not finite, throw or surface a clear error indicating the
invalid flag and key (e.g., `Invalid numeric value for ${key}: ${v}`), otherwise
parse (parseInt for intKeys, parseFloat for floatKeys) and assign to out[key];
apply the same validation to both intKeys and floatKeys branches so invalid
inputs fail fast instead of producing NaN in config.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@commands/skill-optimize.md`:
- Line 34: The example CLI call shows --evaluator-model gpt-4o-mini but omits
--evaluator-provider, which causes the default Anthropic provider to be used;
either update the example to include --evaluator-provider openai (so the example
becomes explicit) or change the CLI logic in scripts/optimize-skill.js that
handles the evaluator-model/evaluator-provider flags to infer provider from the
model name (e.g., map gpt-* to openai) and set evaluator-provider when missing;
touch the evaluator-model/evaluator-provider parsing logic in
scripts/optimize-skill.js to implement the inference if you choose that route.

In `@scripts/optimize-skill.js`:
- Around line 81-90: The optimizer/evaluator provider flags default to
'anthropic', so passing only --evaluator-model (e.g., gpt-4o-mini) can route
OpenAI IDs to Anthropic; update the logic that builds the options object (fields
optimizerProvider, evaluatorProvider sourced from
args.optimizerProvider/evaluatorProvider and
args.optimizerModel/args.evaluatorModel) to either (A) infer provider from the
model id when the corresponding provider flag is absent (e.g., treat model id
starting with "gpt-" or containing "openai" as OpenAI) or (B) validate
compatibility before calling train() (the train() callsite) and throw a clear
error if model/provider mismatch is detected; locate and change the code that
sets optimizerProvider/evaluatorProvider and the train() invocation to perform
this inference/validation and surface a helpful error message.

In `@skills/skill-optimizer/SKILL.md`:
- Line 87: Replace the current phrasing that calls “ReflACT” a SkillOpt named
6-stage pipeline: edit SKILL.md to either (a) directly describe Microsoft
SkillOpt’s six-stage loop by name (rollout → reflect → aggregate → select →
update → evaluate) and keep the arXiv:2605.23904 reference, or (b) explicitly
mark “ReflACT” as a local label in this repo (e.g., “locally labeled ‘ReflACT’
for our adaptation of SkillOpt’s six-stage loop”) so it’s clear the term is not
used in the original SkillOpt paper; update the sentence that mentions “ReflACT”
to follow one of these two options.

In `@src/db/schema.sql`:
- Around line 231-240: The optimization_validation table is currently
append-only and upsertValidation in src/optimizer/store.ts always INSERTs rows
with learning_id=null, causing duplicates; fix by adding a uniqueness constraint
that treats NULL learning_id consistently and update upsertValidation to use an
UPSERT/ON CONFLICT target. Specifically, add a unique index on (skill_slug,
COALESCE(learning_id, -1)) for the optimization_validation table (or equivalent
expression) so NULLs collide, then change upsertValidation to perform INSERT ...
ON CONFLICT(skill_slug, COALESCE(learning_id, -1)) DO UPDATE ... (or use INSERT
OR REPLACE) and ensure it sets learning_id explicitly (or uses COALESCE) rather
than leaving it NULL; this will prevent duplicate rows returned by
listValidation.

In `@src/optimizer/aggregate.ts`:
- Around line 39-41: The patchKey currently collapses distinct patches by
returning `${p.op}::${p.anchor.trim().toLowerCase()}`, causing different `add`
patches (or any patches with identical op+anchor but different `payload`) to
collide in the `seen` map; update patchKey (function patchKey) to include
payload identity for cases where payload matters (e.g., for op === 'add' or when
p.payload !== undefined) — for example by appending a stable representation of
p.payload (JSON.stringify or a content hash) to the key so different payloads
produce distinct keys — then ensure the existing `seen` map and conflict/votes
logic continue to operate against the new key so unique patches are preserved
instead of overwritten.

In `@src/optimizer/apply.ts`:
- Around line 52-58: The current replace branch uses skill.replace(patch.anchor,
patch.payload) which treats $-sequences in patch.payload as special replacement
patterns and can mangle LLM-authored content; change the replacement to use a
function replacer so the payload is inserted verbatim (i.e., call replace with a
function that returns patch.payload) while keeping the existing checks for
patch.op and patch.anchor and the truncate(anchor) error path; update the
expression where skill.replace is invoked to use the function-returning-payload
form to ensure literal insertion.

In `@src/optimizer/llm.ts`:
- Around line 28-34: PRICE_PER_M_TOKENS currently returns {input:0, output:0}
for unknown models which makes costUsd zero and bypasses the budget cap; change
the lookup logic that uses PRICE_PER_M_TOKENS so that if a model key is missing
it does not default to zero but instead logs a clear warning/error (e.g.,
processLogger.warn/error) and fails closed by throwing an Error (or otherwise
rejecting the run) so unlisted models cannot silently accrue $0 cost; update the
code paths that compute costUsd (the place referencing PRICE_PER_M_TOKENS and
producing costUsd) to perform an explicit existence check and handle missing
prices accordingly, and add/update tests to cover the missing-model case.
- Around line 101-119: The postJson function lacks a network timeout, so add a
timer that aborts the HTTPS request and destroys its socket if no response
within a reasonable interval (e.g., configurable constant); attach the timeout
to the req (and handle the 'socket' event to set socket.setTimeout or destroy on
timer), on timeout call req.destroy(new Error('Request timed out')) and reject
the Promise with that error, and ensure you clear the timeout on 'response' end
and on 'error' so you don't leak timers; update uses of req, res, and the
Promise resolution/rejection in postJson accordingly.

In `@src/optimizer/store.ts`:
- Around line 269-278: trajectoriesToValidation currently drops the originating
learningId and upsertValidation inserts every seed with learning_id = null,
causing duplicates on repeated train() runs; restore and persist a stable key
(the original learningId or a deterministic hash of the prompt/expected) in
trajectoriesToValidation so upsertValidation can use it, then change
upsertValidation to perform a real upsert/skip against storage (e.g. enforce a
unique constraint on (skill_slug, learning_id) or (skill_slug, prompt_hash) and
use INSERT ... ON CONFLICT DO NOTHING / UPDATE or a SELECT-then-INSERT guard)
instead of always inserting nulls; apply the same fix to the related insert path
referenced at lines 291-305 so both insertion sites use the stable key and
idempotent upsert behavior (refer to functions trajectoriesToValidation,
upsertValidation, train, and listValidation).

In `@src/optimizer/trainer.ts`:
- Around line 80-86: The current flow lets paid LLM calls (e.g., validateSkill
invoked for initialEval and later candidate/reflect/slow-update evaluations)
start before verifying remaining budget, so a single call can overshoot
args.config.budgetUsd; before every paid call that would create an evaluation
(all validateSkill calls and any reflect/candidate/slow-update evaluator
invocations referenced around the other blocks), add a preflight guard that
computes remaining = args.config.budgetUsd - spent and refuses/short-circuits
the call if remaining <= estimatedCallCost (or <= 0) and return an appropriate
error/result; update spent only after successful calls and centralize the check
so every place that calls validateSkill or the reflect/candidate/slow-update
evaluators performs the same preflight guard.
- Around line 80-90: The run can exit via multiple branches without persisting
updated counters; before calling endRun() ensure you flush the authoritative
metrics by calling store.updateRun(runId, { spentUsd: spent, rejectedSteps:
rejected, epochsCompleted: epochs, bestScore, bestHash, bestSkill, initialScore:
/* keep existing */ }); Add this final updateRun() immediately prior to any
endRun() invocation (and in the early-continue/exit paths that currently skip
it) so spent, rejected and epochs are always written; reference the variables
spent, rejected, epochs, bestScore, bestHash, bestSkill and the functions
store.updateRun and endRun to locate where to insert the call.
- Around line 215-241: The slow-update branch currently calls runSlowUpdate({
recentlyAcceptedDiffs: [] }) and uses a raw >= bestScore acceptance check, which
prevents consolidation from seeing recent diffs and weakens the acceptance gate;
update this flow so runSlowUpdate is invoked with the actual recently accepted
diffs (pass the variable that tracks recentlyAcceptedDiffs instead of an empty
array) and apply the same acceptance criterion used elsewhere (compute delta =
slowEval.result.weightedScore - bestScore and compare against acceptThreshold)
before promoting bestSkill/bestHash and calling store.updateRun so the
consolidation step receives the accepted diffs and the slow update uses the
identical acceptance logic as regular candidates.
- Around line 281-284: The writeBestSkill function currently appends a new
optimizer stamp each run; change it to replace any existing optimizer stamp(s)
instead: in writeBestSkill, if the target file exists read its current contents
(or operate on the incoming content if you prefer), remove any lines matching
the optimizer stamp pattern (e.g. /^<!-- skill-optimizer: .* -->\s*$/ or a
global regex that finds all <!-- skill-optimizer: ... --> entries), then append
a single fresh stamp variable (stamp) to the cleaned content and write that with
writeFileSync; keep the stamp string and function name (writeBestSkill) intact
but ensure you dedupe/remove stale stamps before writing.

---

Nitpick comments:
In `@commands/skill-optimize.md`:
- Line 11: The markdown file contains fenced code blocks that lack a specified
language (MD040); update each triple-backtick fence in
commands/skill-optimize.md to include an explicit language token (e.g., change
``` to ```text or ```bash) for the blocks referenced (the fences around the
snippets at the reported locations), ensuring all code fences across the file
include a language to satisfy markdownlint.

In `@scripts/optimize-skill.js`:
- Around line 38-39: The current branch that sets numeric config values (using
intKeys, floatKeys, out, key, v) can insert NaN when v is missing or
non-numeric; add a guard before parsing: if v is null/empty or Number(v) is NaN
or not finite, throw or surface a clear error indicating the invalid flag and
key (e.g., `Invalid numeric value for ${key}: ${v}`), otherwise parse (parseInt
for intKeys, parseFloat for floatKeys) and assign to out[key]; apply the same
validation to both intKeys and floatKeys branches so invalid inputs fail fast
instead of producing NaN in config.

In `@skills/skill-optimizer/SKILL.md`:
- Line 24: The fenced code block in SKILL.md is missing a language specifier
which triggers markdownlint MD040; update the triple-backtick fence on the block
at the shown location to include a language (for example use ```text) so the
fence becomes a labeled code block and satisfies the linter.

In `@src/optimizer/reflect.ts`:
- Around line 99-101: Duplicate fence-stripping and JSON-parse logic exists in
stripFences (function name) and similar code in validate.ts and slow.ts; extract
a single helper (e.g., stripFencesAndParse) that 1) strips leading ``` or
```json and trailing ``` using the existing regex, 2) safely parses JSON in a
try/catch and returns either the parsed object or a controlled error/undefined,
and 3) replace the inline logic in stripFences, validate.ts, and slow.ts to call
this new helper so all modules share identical parsing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 455854cb-5588-42c4-8d32-195b125bcd33

📥 Commits

Reviewing files that changed from the base of the PR and between a2c6df1 and 7a87f81.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (17)
  • commands/skill-optimize.md
  • package.json
  • scripts/optimize-skill.js
  • skills/skill-optimizer/SKILL.md
  • src/db/schema.sql
  • src/optimizer/__tests__/apply.test.ts
  • src/optimizer/aggregate.ts
  • src/optimizer/apply.ts
  • src/optimizer/clip.ts
  • src/optimizer/hash.ts
  • src/optimizer/llm.ts
  • src/optimizer/reflect.ts
  • src/optimizer/slow.ts
  • src/optimizer/store.ts
  • src/optimizer/trainer.ts
  • src/optimizer/types.ts
  • src/optimizer/validate.ts

Comment thread commands/skill-optimize.md Outdated
Comment thread scripts/optimize-skill.js
Comment thread skills/skill-optimizer/SKILL.md Outdated
Comment thread src/db/schema.sql
Comment thread src/optimizer/apply.ts
Comment thread src/optimizer/store.ts
Comment thread src/optimizer/trainer.ts Outdated
Comment thread src/optimizer/trainer.ts Outdated
Comment thread src/optimizer/trainer.ts Outdated
Comment thread src/optimizer/trainer.ts
13 review findings, all valid, all fixed:

1. CLI infers provider from model id (claude-* -> anthropic, gpt-*/o* -> openai). Mixed-provider example in commands/skill-optimize.md no longer requires --evaluator-provider for gpt-4o-mini.

2. NaN guard on numeric flag parsing: --epochs notanumber now errors fast instead of producing NaN in config.

3. SKILL.md drops the 'ReflACT' label since it is not a SkillOpt term; loop is referred to by stage names only.

4. optimization_validation gains prompt_hash + unique(skill_slug, prompt_hash); upsertValidation now uses ON CONFLICT DO UPDATE and persists learning_id from trajectoriesToValidation. Eliminates duplicate validation rows on repeated train() runs.

5. aggregate.patchKey now includes payload, so two distinct add proposals at the same anchor are preserved. Conflict counter moved to a separate anchor-level set so 'same anchor, different payloads' is still surfaced as a conflict.

6. apply.ts replace branch uses skill.replace(anchor, () => payload) function form. Payloads containing $1 / $& / $` are no longer mangled as backreferences.

7. llm.ts fails closed on unknown models: PRICE_PER_M_TOKENS lookup throws with the known-model list instead of silently returning {0,0} and bypassing budget cap.

8. llm.ts postJson gains an abortable timeout (default 120s, override via SKILL_OPTIMIZER_TIMEOUT_MS). Timer cleared on response end and on error to avoid leaks.

9. trainer.ts adds a preflight guardBudget() check before every paid LLM call (baseline eval, reflect, evaluate, slow update, slow update eval). Loop short-circuits when remaining budget cannot cover the call.

10. trainer.ts flushes spent / accepted / rejected / epochs / bestSkillHash / bestScore via flushAndEnd() before every endRun() call (completed / failed / stopped). Counters now persist even on early-exit branches.

11. trainer.ts slow update now passes recentlyAcceptedDiffs (last 8) and uses delta >= acceptThreshold against the gate, identical to the per-step gate. Previously consolidation saw an empty diff list and used raw >= bestScore.

12. trainer.ts writeBestSkill strips any prior <!-- skill-optimizer: ... --> stamp before writing the new one, so re-runs do not accumulate stamps. stripExistingStamp exported for test coverage.

13. Shared parse.ts (stripFencesAndParse) extracted from reflect.ts / validate.ts / slow.ts. All three modules now share identical fence-strip + try/catch JSON parse behavior.

Tests: 13/13 pass (4 new cases for the new behavior). Build + typecheck clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/optimizer/aggregate.ts`:
- Around line 43-49: The keys used for aggregation lowercasing and trimming
anchors causing distinct anchors to be collapsed; update patchKey and
anchorKeyFor to use the anchor exactly as-used by applyPatches (no .trim() and
no .toLowerCase()) so identity is consistent with applyPatches' exact matching;
ensure the functions patchKey and anchorKeyFor concatenate p.anchor verbatim
(still include p.op and p.payload as before) to avoid discarding patches that
would actually apply.

In `@src/optimizer/parse.ts`:
- Around line 1-7: The stripFencesAndParse function fails to remove fenced JSON
when the input has leading whitespace/newlines; update stripFencesAndParse to
first trim or otherwise ignore leading/trailing whitespace, then apply regexes
that match the opening fence even after whitespace (e.g., use text.trim() or
text.replace(/^\s*/, '') before replacing /^```(?:json)?/i and /\s*```$/ to
remove fences robustly), and then JSON.parse the resulting stripped string,
keeping the function signature and error handling unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e11f568-2aef-423c-9b9d-73e315c38304

📥 Commits

Reviewing files that changed from the base of the PR and between 7a87f81 and 41e5b50.

📒 Files selected for processing (14)
  • commands/skill-optimize.md
  • scripts/optimize-skill.js
  • skills/skill-optimizer/SKILL.md
  • src/db/schema.sql
  • src/optimizer/__tests__/apply.test.ts
  • src/optimizer/aggregate.ts
  • src/optimizer/apply.ts
  • src/optimizer/llm.ts
  • src/optimizer/parse.ts
  • src/optimizer/reflect.ts
  • src/optimizer/slow.ts
  • src/optimizer/store.ts
  • src/optimizer/trainer.ts
  • src/optimizer/validate.ts
✅ Files skipped from review due to trivial changes (2)
  • commands/skill-optimize.md
  • skills/skill-optimizer/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (9)
  • src/db/schema.sql
  • src/optimizer/validate.ts
  • src/optimizer/reflect.ts
  • src/optimizer/slow.ts
  • src/optimizer/llm.ts
  • src/optimizer/apply.ts
  • scripts/optimize-skill.js
  • src/optimizer/trainer.ts
  • src/optimizer/store.ts

Comment on lines +43 to +49
function patchKey(p: Patch): string {
return `${p.op}::${p.anchor.trim().toLowerCase()}::${p.payload}`;
}

function anchorKeyFor(p: Patch): string {
return `${p.op}::${p.anchor.trim().toLowerCase()}`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep anchor identity consistent with applyPatches.

src/optimizer/apply.ts matches anchors exactly, but patchKey/anchorKeyFor lowercase and trim them here. That lets distinct patches like ## Rules vs ## rules or anchors that intentionally include boundary whitespace collapse into one vote/conflict bucket, so aggregation can keep a patch that will never apply and discard the one that would.

Suggested fix
 function patchKey(p: Patch): string {
-  return `${p.op}::${p.anchor.trim().toLowerCase()}::${p.payload}`;
+  return `${p.op}::${p.anchor}::${p.payload}`;
 }
 
 function anchorKeyFor(p: Patch): string {
-  return `${p.op}::${p.anchor.trim().toLowerCase()}`;
+  return `${p.op}::${p.anchor}`;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function patchKey(p: Patch): string {
return `${p.op}::${p.anchor.trim().toLowerCase()}::${p.payload}`;
}
function anchorKeyFor(p: Patch): string {
return `${p.op}::${p.anchor.trim().toLowerCase()}`;
}
function patchKey(p: Patch): string {
return `${p.op}::${p.anchor}::${p.payload}`;
}
function anchorKeyFor(p: Patch): string {
return `${p.op}::${p.anchor}`;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/optimizer/aggregate.ts` around lines 43 - 49, The keys used for
aggregation lowercasing and trimming anchors causing distinct anchors to be
collapsed; update patchKey and anchorKeyFor to use the anchor exactly as-used by
applyPatches (no .trim() and no .toLowerCase()) so identity is consistent with
applyPatches' exact matching; ensure the functions patchKey and anchorKeyFor
concatenate p.anchor verbatim (still include p.op and p.payload as before) to
avoid discarding patches that would actually apply.

Comment thread src/optimizer/parse.ts
Comment on lines +1 to +7
export function stripFencesAndParse<T = unknown>(text: string): T | null {
const stripped = text.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
try {
return JSON.parse(stripped) as T;
} catch {
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle leading whitespace before fenced JSON.

This only strips fences when the very first character is ```, so a response that starts with a blank line or spaces before the fence falls through to JSON.parse, returns `null`, and `src/optimizer/reflect.ts` / `src/optimizer/validate.ts` silently treat that as no patches/outcomes.

Suggested fix
 export function stripFencesAndParse<T = unknown>(text: string): T | null {
-  const stripped = text.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
+  const stripped = text
+    .trim()
+    .replace(/^```(?:json)?\s*/i, '')
+    .replace(/\s*```\s*$/, '');
   try {
     return JSON.parse(stripped) as T;
   } catch {
     return null;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/optimizer/parse.ts` around lines 1 - 7, The stripFencesAndParse function
fails to remove fenced JSON when the input has leading whitespace/newlines;
update stripFencesAndParse to first trim or otherwise ignore leading/trailing
whitespace, then apply regexes that match the opening fence even after
whitespace (e.g., use text.trim() or text.replace(/^\s*/, '') before replacing
/^```(?:json)?/i and /\s*```$/ to remove fences robustly), and then JSON.parse
the resulting stripped string, keeping the function signature and error handling
unchanged.

Five issues caught reading the prior commit cold.

1. trainer.ts writeBestSkill: skip the write entirely when bestHash === initialHash. Previously every run rewrote SKILL.md with a fresh stamp even when no candidate beat the baseline, polluting the file with optimizer metadata on no-op runs.

2. trainer.ts makeMinibatches: replaced biased Array.sort(() => Math.random() - 0.5) with Fisher-Yates shuffle. The sort-based shuffle is non-uniform because the comparator violates transitivity; Fisher-Yates is the standard fix.

3. trainer.ts step loop: moved the budget guard above store.saveCandidate / store.recordPatches. Previously a budget exhaustion right before the evaluator left orphaned 'pending' candidates and their patches persisted in the DB with no eval to follow.

4. trainer.ts validation guard: throws early if the validation set has fewer than 2 items (e.g. --holdout 0 or pathological trajectories). Empty validation made every candidate score 0, delta 0 >= 0 threshold, trivially accepting garbage edits.

5. trainer.ts STAMP_RE: dropped the trailing $ anchor so a global replace strips ALL accumulated <!-- skill-optimizer: ... --> stamps, not just the last one. Pre-existing risk only on files that already had multiple stamps from older runs.

Tests: 14/14 pass (1 new case for multi-stamp strip).
@rohitg00 rohitg00 merged commit 5c313e2 into main Jun 3, 2026
5 checks passed
@rohitg00 rohitg00 deleted the feat/skill-optimizer branch June 3, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant