Capstone 8B — Legacy Migration Agent, Skills-First
Build the Oracle → PostgreSQL migration agent again — same schema, same planted defects, same twenty evaluation cases — with Agent Skills instead of subagents. Then measure which architecture actually did better, and write down the answer even when it is unflattering.
Project Brief
You already built this system. Meridian Public Records, eleven Secretary of State offices, 6 TB of UCCUniform Commercial Code — the body of US law governing secured transactions. A UCC-1 financing statement is the public filing a lender makes to claim an interest in a borrower's assets. filings on Oracle since 2003, both PL/SQL authors retired in 2021. In Capstone 8 you migrated it with a coordinator and five specialist subagents.
Now look back at three of those five specialists — schema-translator, plsql-converter, appsql-rewriter. Read their prompts again. None of them is really delegating work. Each one is a rulebook that happens to have a context window attached to it: the Oracle → PostgreSQL type matrix, the PL/SQL construct catalog, the rewrite rules.
That is what Agent SkillsAgent Skill — a directory under .claude/skills/ containing a SKILL.md with YAML frontmatter, optionally alongside references/ (material loaded on demand) and scripts/ (executables the agent runs). Claude loads a skill when the request matches its description. exist to hold. So this capstone takes the rulebooks out of the subagents and ships them as skills.
Think about the difference between handing a job to a colleague and handing yourself a manual.
Before: you have a hard task and two ways to get help. You can call in a specialist — brief them, wait, take their answer. Or you can pull the reference manual off the shelf and do it yourself.
The pain is that these feel interchangeable and are not. The specialist gives you something you cannot give yourself: an opinion formed without knowing what you already decided. The manual gives you something the specialist cannot: it is the same manual you hand to the person who checks your work afterwards, so the two of you cannot be working from different editions.
Mapping onto this project: a subagent is the colleague — separate context, independent read, and no memory of your reasoning. A skill is the manual — loaded into your own context, shared with every phase that needs it, and unable to tell you anything you did not just work out yourself. Capstone 8 was built out of colleagues. This one is built out of manuals. Neither is the right answer everywhere, and this lab is how you find out where each one belongs.
Do Capstone 8 first. This is a comparison, and a comparison needs a baseline. If you have not built the subagent version and seen its numbers, most of this page will read as assertion rather than evidence.
The thesis, stated once, because every section below returns to it:
Skills are knowledge loaded into the current context. Subagents are work delegated to a separate one.
Collapsing five subagents into one context buys shared knowledge and costs independence — and the phase that needs independence most is the one that checks the work.
The deliverable includes one artifact Capstone 8 does not produce: evaluation/architecture_comparison.md, a measured comparison of the two builds on the same problem. It is scored on honesty, not on the numbers being favourable. A comparison that flatters the newer architecture is a failed deliverable — it is worth nothing to the person who reads it deciding what to build next.
Prerequisites
- Capstone 8, completed and run. You need its
artifacts/migration_report.jsonfor the baseline column. - M25 — Claude Code Mastery for the six configuration layers, and M26 for hooks and the Agent SDK.
- Docker (or Rancher Desktop) with ~6 GB free. Oracle Free 23c is a large image.
- Python 3.11+, an
ANTHROPIC_API_KEY, and roughly $2 of budget per full run.
Budget note. Capstone 8's reference run cost $1.78 in output tokens. 8B has no per-phase model routing — every phase runs on Sonnet, where Capstone 8 sent the mechanical phases to Haiku. Expect 8B to cost more, and treat that difference as one of the things you are measuring rather than a surprise.
Skill or Subagent?
The question is not "which is better". It is "which problem am I solving". And it is not a two-way choice, because one frontmatter line moves a skill across the divide: context: fork makes a skill spawn a subagent, so it runs in its own window and returns only a result.
So there are three shapes, not two:
| Inline skill (the default) | Forked skillcontext: fork | Subagent | |
|---|---|---|---|
| Context | Yours, shared | Its own | Its own |
| Independent judgement | No | Yes | Yes |
| Reuse | One file, many loaders | One file, many loaders | Copy the prompt |
| Model | Whatever the caller is | model:, or the agent's | Chosen per specialist |
| System prompt | The caller's | The agent type's | Written for the job |
| Cost when used | Tokens in your window | A whole extra turn | A whole extra turn |
| Can ship code | Yes — scripts/ | Yes — scripts/ | No — needs a tool |
Run Capstone 8's five specialists through that last row and they sort themselves:
| Specialist | What it actually is | Becomes |
|---|---|---|
schema-translator | The type-mapping rulebook | Inline skill |
plsql-converter | The construct catalog + refusal rules | Inline skill |
appsql-rewriter | The rewrite rulebook | Inline skill |
data-migrator | Long per-table work, wants its own window | Inline, or forked |
migration-validator | Must not have seen the work it audits | Forked skill or subagent |
Notice that the honest answer already refuses to make the last one a plain inline skill. This lab makes all five inline anyway — not because that is the right production design, but because converting the one that should not be converted is how you measure what the conversion costs. You cannot price independence without giving it up once.
Keep context: fork in view while you read the rest. It is the cheapest available fix for the problem this design creates, it is one line, and The Validator Problem is where you will want it.
🎓 Cert Tip. The Claude Certified Architect exam asks you to place a capability into the right configuration layer — CLAUDE.md, skills, slash commands, hooks, subagents, MCP. The reliable discriminator is the first row of the table above: does this need a separate context, or does it need to be shared? Reusable procedural knowledge is a skill. Deterministic enforcement is a hook, never either. But do not answer "needs isolation → subagent" reflexively — a skill with context: fork gets isolation too. Reach for a real subagent when you also need a system prompt written for that job and its own model routing.
Animation 1: The Same Five Phases, Two Shapes
Both builds run the identical pipeline: discover, schema, data, code, validate. Watch where the knowledge lives as each phase fires.
What just happened? On the left, each phase hands its object to a specialist that starts from nothing and returns an answer. Five contexts, five fresh starts, five prompts that each carry their own copy of whatever rules they need.
On the right, one context runs the whole way through. Skills slide in when a phase needs them and slide out when it does not. The pipeline is identical; only the location of the knowledge moved.
Anatomy of a Skill
A skill is a directory, not a file. Three parts, each with a different loading cost:
That third row is the one people underuse. A scripts/ file can be run without its source ever entering the context window — the agent pays for the output, not the program. For anything deterministic, that is strictly better than reasoning:
python .claude/skills/oracle-pg-typing/scripts/check_mapping.py \
--ddl ../legacy-oracle/01_schema.sql
filed_date DATE -> timestamp(0)
[confident] Oracle DATE carries a TIME component. Mapping it to `date`
silently truncates 14:32:07 to midnight, which changes which rows
appear to have lapsed.
? records_expected NUMBER -> numeric
[check_data] NUMBER with no declared precision can carry a scale;
narrowing it to an integer type would silently truncate.
46 columns, 20 need a look at the data.
Forty-six columns triaged, deterministically, for the cost of forty lines of output. The model now spends its judgement on the twenty that genuinely need it instead of re-deriving whether NUMBER(9) fits in an int.
The skill did not tell Claude the answer and it did not make Claude work the answer out. It handed Claude a program and told it when to run it. The mapping table is now unit-tested, diffable in review, and identical on a bad day — three properties a paragraph in a prompt can never have.
Here is the real SKILL.md from the lab, trimmed to its skeleton. Note how short it is: the long material lives in references/ and is pulled in only when the agent hits the case.
---
name: oracle-pg-typing
description: This skill should be used when mapping Oracle column types to
PostgreSQL 16 -- when the user or the migration asks to "translate a table",
"generate DDL", "map this column", "what does NUMBER(9) become", or when any
Oracle type name (NUMBER, VARCHAR2, DATE, RAW, CLOB, TIMESTAMP WITH LOCAL
TIME ZONE) needs a PostgreSQL equivalent.
allowed-tools: [Read, Bash, mcp__oracle_src__oracle_get_ddl,
mcp__oracle_src__oracle_sample_rows, mcp__pg_target__pg_apply_ddl,
mcp__migration_local__write_artifact]
---
# Oracle to PostgreSQL type mapping
## The rule that governs everything else
**The DDL tells you the declared type. The rows tell you what the column
actually holds. Those are different questions, and the mapping depends on
the second one.**
So the procedure is always two reads, never one.
## Step 1 -- Run the checker before you reason
Load `references/type-matrix.md` for the full table, and
`references/number-precision.md` for the NUMBER(p,s) rules. Do not load
either speculatively -- they are long, and you need the context for the rows.
Why it matters. The description is not documentation — it is the trigger. It is the only thing Claude reads when deciding whether this skill is relevant, and it is read against the user's phrasing, not yours. A description reading "Type mapping utilities" never fires. One that names the exact phrases a caller would use — and the type names that appear in the work — fires reliably. Write it in the third person, and put the vocabulary of the problem in it.
The Frontmatter Contract
Two keys are required. The rest are optional, and three of them decide something this capstone spends the whole page on: where the skill runs.
| Key | Required | What it does |
|---|---|---|
name | Yes | Display name; defaults to the filename. Must match the directory |
description | Yes | One-line summary shown in listings and the Skill tool — this is what decides whether the skill is ever loaded |
context | No | inline (default) expands the skill into the current conversation; fork spawns a subagent |
agent | No | Which agent type to spawn when context: fork |
background | No | Only for forks. The fork reports back as a task notification instead of blocking the turn; false keeps the caller waiting in-line |
allowed-tools / disallowed-tools | No | Tools added or removed while the skill is active |
when_to_use | No | Extra triggering guidance, folded into the Skill tool description |
paths | No | Globs. The skill loads only when the model touches matching files |
hooks | No | Hooks registered while the skill is active, same shape as settings.json |
model, effort, shell | No | Model override, thinking effort, and the shell for !-blocks |
user-invocable, disable-model-invocation, argument-hint | No | Who may invoke it, and the slash-command placeholder |
The one that matters here is context.
A skill with context: fork is not just knowledge loaded into your window — it spawns a subagent. The skill runs in its own context, and only its result comes back.
So "skill" and "subagent" are not two ends of a spectrum. One frontmatter line moves a skill from one to the other.
Every skill in this lab runs inline, and that is a deliberate experimental choice rather than a default nobody examined. One shared context is the architecture being measured against Capstone 8. The Validator Problem is where that choice costs something — and where context: fork is the lever sitting right there.
An unrecognised key is ignored silently. Not rejected, not warned about — ignored. A skill with contxt: fork, or with a key someone assumed existed, loads and runs and behaves exactly as if the line were absent, while its author believes it is isolated.
The same is true of a valid key with an invalid value: context: forked is not fork, and nothing tells you.
Which is why the lab asserts the contract at build time rather than hoping to notice at runtime:
# Taken from Claude Code's own schema, not from a survey of published
# skills -- a key being absent from every example you can find is not
# evidence that it is unsupported.
REQUIRED_KEYS = {"name", "description"}
OPTIONAL_KEYS = {
"model", "allowed-tools", "disallowed-tools", "argument-hint",
"disable-model-invocation", "user-invocable", "effort", "shell",
"when_to_use", "paths", "hooks", "context", "agent", "background",
...
}
CONTEXT_VALUES = {"inline", "fork"}
@pytest.mark.parametrize("skill", SKILL_NAMES)
def test_no_invented_frontmatter_keys(skill, parsed):
data, _ = parsed[skill]
unknown = set(data) - KNOWN_KEYS
assert not unknown, (
f"{skill}: unrecognised frontmatter key(s) {sorted(unknown)}. "
f"Claude Code ignores unknown keys silently."
)
@pytest.mark.parametrize("skill", SKILL_NAMES)
def test_context_value_is_valid_if_present(skill, parsed):
"""`inline` expands into the conversation; `fork` spawns a subagent.
Anything else leaves the skill running inline while its author
believes it is isolated."""
data, _ = parsed[skill]
if "context" not in data:
pytest.skip(f"{skill} does not set context (defaults to inline)")
assert data["context"] in CONTEXT_VALUES
You wrote a test for a configuration file, which feels excessive until you internalise the failure mode. A broken subagent throws. A broken tool call throws. A broken skill does not — it loads, ignores what it does not recognise, and the agent improvises. This is the only class of bug in the whole capstone with no runtime signal at all, so the signal has to be manufactured at build time.
🎓 Cert Tip. Know what context: fork does, and know that it blurs the line the exam otherwise draws between skills and subagents. A forked skill is a subagent invocation wearing skill frontmatter: own context window, result returned, caller's window untouched. What it does not give you is a separate system prompt and per-specialist model routing designed around one job — that is still what a real subagent definition is for.
Animation 2: Progressive Disclosure, Measured
A subagent prompt loads whole, every time it is invoked. A skill loads in layers. Watch the token counter as the agent works through one table and only pulls in what it hits.
What just happened? The skill's entry point is small enough to keep resident. The long material — the full type matrix, the NUMBER(p,s) bands — arrives only when the agent meets a column that needs it, and the scripts/ file never enters the window at all.
The comparison against the subagent is not automatically favourable, and you should be suspicious of any page that tells you it is. A subagent's prompt is loaded once per invocation, into a context that is then thrown away. A skill's material is loaded into a context that persists for the rest of the run. Which wins depends on how many times the phase repeats and how much of the reference material actually gets touched.
Why it matters. Phase 2 runs six times, once per table. Under subagents, each run pays the schema-translator prompt and then discards it — six identical charges, zero residue. Under skills, the material is paid for once and then stays, competing with the DDL and sample rows for the rest of the migration. Six tables is roughly where these two trade places. Your job in architecture_comparison.md is to find out which side of that line this migration falls on, and to say so with numbers rather than with a diagram.
Animation 3: The Phase → Skill Map
A subagent's tools: frontmatter stops it reaching things it has no business touching. The skills build needs the same containment, and gets it from the SDK.
PHASE_SKILLS: dict[str, list[str]] = {
"discover": [],
"schema": ["oracle-pg-typing"],
"data": ["nullability-preservation"],
"code": ["plsql-conversion", "appsql-rewriting"],
"validate": ["migration-validation", "nullability-preservation"],
"cutover": [],
}
Three decisions in that map are worth defending out loud:
discoverloads nothing. Taking an inventory needs no rulebook. Loading the type matrix here would put it in the window for the whole run to no purpose.datacannot loadmigration-validation. The phase doing the work must not be able to reach the procedure that decides whether the work was correct. It could not mark its own homework even if it tried.nullability-preservationappears twice. That is the next section, and it is the strongest argument this architecture has.
Animation 4: Context Growth Across Six Tables
Phases 2 and 3 each iterate the six tables. Watch what happens to the context window under each architecture.
What just happened? The subagent line is a sawtooth — each table gets a fresh window that is discarded when the specialist returns. The skills line climbs and does not come back down, because every table's DDL, sample rows and decisions stay in the one conversation.
Read that carefully, because the shape alone does not tell you which is better. A monotonic climb is only a problem if it reaches the ceiling; below the ceiling, the accumulated context is the thing that lets phase 4 remember what phase 2 decided. Six tables and 19,065 rows is comfortably inside a 200K window. Six hundred tables would not be.
Where this actually bites. Not on this schema. The failure mode arrives when a migration has enough objects that the accumulated context crosses the compaction threshold mid-run — and the phase that gets compacted is, by construction, the earliest one, which holds the type decisions everything downstream depends on. If you are sizing this pattern for a real 6 TB migration rather than a 19,065-row teaching schema, that is the number to compute first.
Tool or Script? The One That Moved
Capstone 8's tools_local.py exposed two MCP tools. 8B's exposes one. scan_app_sql is gone, and the same regex now ships as appsql-rewriting/scripts/find_oracleisms.py.
| MCP tool | Skill script | |
|---|---|---|
| Present in the tool list | Every request, always | Never — it is a file |
| Costs context when unused | Yes, its schema | No |
| Where its rationale lives | Somewhere else entirely | Next to it, in SKILL.md |
| Enforces a boundary | Can | Cannot |
That last row is why write_artifact stays a tool. It is not knowledge — it is the thing that confines every generated file to artifacts/, and that boundary has to hold in phases where no relevant skill is loaded at all. A capability that must always be true cannot live somewhere that is only sometimes loaded.
The rule: if it is knowledge about how to do something, it belongs in a skill, next to the prose explaining when to use it. If it is a capability or a boundary that must hold regardless of context, it belongs in a tool.
Read the module docstring the lab ships in place of the deleted tool — the absence is documented, because a future reader will otherwise assume it was an oversight:
"""Local (non-database) tools: artifact writing.
## What is NOT here, and why
The subagent build of this migration (Capstone 8) also exposed a
`scan_app_sql` MCP tool here. In the skills build it is gone -- the same
scanning logic now ships as
`.claude/skills/appsql-rewriting/scripts/find_oracleisms.py`, and the agent
runs it with Bash.
MCP tool always present, described in every request's tool list,
costs context whether or not this phase needs it, and its
*rationale* lives somewhere else entirely.
Skill script loaded only when the skill loads, and it sits next to the
SKILL.md that explains when to run it and how to read the
output.
`write_artifact` stays an MCP tool, because it is genuinely a capability
rather than knowledge: it enforces the artifacts/ confinement boundary, and
that boundary must hold in every phase regardless of which skills happen to
be loaded.
"""
Animation 5: The Validator Problem
This is the cost. Everything above was instrumentation for measuring it.
In Capstone 8, migration-validator started from an empty context. It had never seen the reasoning that chose timestamp(0) over date, or the decision to let null_as default. When it said "all clear", that was an opinion formed independently of the work.
In 8B, phase 5 is phases 2 through 4. Everything it is auditing is already in its context, written by it, and — from its point of view — already justified.
Note carefully what this is and is not. It is a consequence of this lab running every skill inline, not an inherent property of skills: context: fork would give the validator its own window. The inline choice is what makes the cost measurable, and the fork is the fix waiting at the end of the measurement.
A validator that reports "all clear" on a broken load is worse than no validator, because it converts an unknown risk into a false assurance. Nobody re-checks a migration that passed.
The lab compensates in two places. First, in the skill itself:
### The bias you are running with
In the subagent build of this migration, validation ran in its own context and
had never seen the loading decisions. **Here it does not.** You are the same
context that chose the type mappings and ran the loads. You will be inclined to
accept your own work.
Two rules follow, and they are not optional:
1. **Re-derive every number from the database.** Never report a count you
remember writing. If you did not just query it, you do not know it.
2. **Assume the load is broken until a query says otherwise.** Start from
suspicion, not from your recollection that phase 3 went fine.
Second, in the phase prompt, which says the same thing in the imperative. And then — because an instruction is a hope, not a mechanism — the lab makes you measure whether it worked.
The experiment. Run phase 3 deliberately broken, with null_as omitted. Then run phase 5 and record two things: did it report the defect, and did it re-query or did it cite phase 3? Repeat three times, because one trial tells you nothing.
The second column is answerable from the audit log rather than from the prose the agent produced:
import json
calls = [json.loads(l) for l in open("migration_audit.jsonl", encoding="utf-8")]
reads = [c for c in calls
if "pg_query" in c["tool_name"] or "pg_row_count" in c["tool_name"]]
print(f"{len(reads)} target reads during the run")
If phase 5 emitted no reads against the target, it did not re-derive anything — it reported from memory, and a clean result from it means nothing at all. That is a measurable property, not a vibe, and it is the single most important number in the whole comparison.
Three ways out, and they are not equivalent
Once you have the numbers, the interesting part is what you do about them. There are three fixes, in increasing order of what they cost you:
| Fix | What it costs | What it keeps |
|---|---|---|
| Instruct harder sharpen the two rules in SKILL.md |
Nothing — but an instruction is a hope, not a mechanism. Measure it; do not assume it. | Everything |
Fork the skillcontext: fork on migration-validation |
One extra turn. The fork cannot see the shared context, so it also loses whatever phases 2–4 legitimately established. | The single shared rulebook — nullability-preservation is still one file |
| Restore the subagent back to Capstone 8's shape |
An extra turn, plus the rule duplicated into an agent prompt where it can drift again | A system prompt written for auditing, and its own model |
The middle row is the one this capstone exists to surface. It is one line of frontmatter. It buys back exactly the property the inline design gave up — a context that has not seen the work — without giving up the single shared file that made the skills architecture worth building. Whether it is enough is an empirical question, which is why Going Further asks you to run the three trials again with the validator forked.
A forked skill is not a free subagent. It runs under the agent type you name in agent:, with that agent's system prompt — not one written for adversarial reconciliation. If your measurements show the fork catches the defect but reports it limply, that gap is the argument for a real subagent, and it belongs in your write-up.
Why it matters. There is a defensible answer to this lab in which the honest conclusion is "keep four inline skills and fork the fifth", and another in which it is "put the validator back as a subagent". If your measurements support one, write it down and say why. The course is not trying to sell you skills; it is trying to give you the evidence to choose. A capstone whose conclusion was fixed before the experiment ran would be worth nothing.
Animation 6: Guardrails, Unchanged
Three PreToolUse guards and a PostToolUse audit log. They are byte-identical to Capstone 8's, and the point of showing them again is that nothing about them needed to move.
What just happened? Nothing changed — and that is the finding. Guardrails sit between the agent and the tools, so they are indifferent to how the agent's knowledge is organised. hooks.py, hooks_cli.py and .claude/settings.json carried across untouched.
🎓 Cert Tip. This is the layering principle the exam keeps returning to. A validation check written inside a tool protects that tool. A can_use_tool callback protects every tool that exists now and every tool anyone adds later — and, as this capstone demonstrates, it keeps protecting them through an architecture rewrite. Enforcement belongs in hooks. Never in a prompt, and never in a skill.
When a Skill Silently Does Not Load
Two ClaudeAgentOptions fields make skills work. Both are required, and omitting either produces no error.
return ClaudeAgentOptions(
model=model,
system_prompt=system_prompt,
mcp_servers={...},
can_use_tool=hooks.can_use_tool,
hooks=[HookMatcher(matcher="*", hooks=[hooks.audit_log])],
# Without this, `.claude/skills/` is never read and every skill
# silently does not exist. The agent then improvises the type
# mapping from memory and the run looks like it worked.
setting_sources=["project"],
# Per-phase allowlist. `None` would mean "no skills"; `"all"` would
# mean every discovered skill in every phase, which defeats the
# scoping this architecture depends on.
skills=skills,
)
Miss setting_sources and the skills directory is never opened. Miss skills and nothing is selected. In both cases Claude answers from its own knowledge of Oracle and PostgreSQL — which is quite good, and that is precisely the problem. The DDL it generates will be plausible. It will also have decided DATE on its own.
Do not trust a green run. Check the audit log for an execution of a bundled script:
grep -c "check_mapping" migration_audit.jsonl # must be > 0
python coordinator.py --list-skills # sanity-check the map first
A zero means the skill never loaded, whatever the migration report says.
You added a verification step for something that has no failure signal. Notice the pattern across this whole capstone: skills fail quietly, so every safeguard around them is an assertion you write yourself — a build-time test on the frontmatter, a runtime grep on the audit log. Subagents did not need either, because a missing subagent throws.
File Structure
Everything that is not agent logic ships complete. You build the skills, the phase map, the guardrails, and the comparison.
Note what is absent from the root, compared with Capstone 8: no type_mapping.py, no oracle_constructs.py, no validation.py, and no .claude/agents/ at all. That logic moved inside the skills that explain it. If you generate this project from the spec and those files come back, the generator produced the Capstone 8 shape.
Spec-Driven: Build It Twice
This is a Tier 3 capstone, so the spec is the source and the solution is the reference:
/generate-from-spec spec/agent-spec.md # writes into generated/
diff -r generated/ solution/ # where they differ, decide who is right
Then do the thing this capstone makes possible and Capstone 8 alone does not: read the two specs side by side.
| Section | Capstone 8 vs 8B |
|---|---|
| 1. Business Context | Same problem, different architecture paragraph |
| 2. Agent Configuration | Differs — no model routing; adds skill discovery |
| 4. Tools | Differs — one local tool instead of two |
| 5. Subagents / Skills | Entirely different |
| 6. Hooks | Identical |
| 7. Guardrails | Identical |
| 8. Sessions | Identical mechanism, one new consequence |
| 9. Deployment | Identical |
| 10. Tests | Same, plus test_skills_wellformed.py |
| 11. Evaluation | Identical — that is what makes scores comparable |
| 12. File Structure | Differs |
Four sections differ out of twelve. Everything you would call "the safety architecture" is in the eight that do not. That is the most reusable thing on this page: the skills-versus-subagents question is a question about where knowledge lives, and it is close to orthogonal to how the system is made safe.
Step-by-Step Build Guide
What & why: the same two containers as Capstone 8. Oracle Free 23c seeds itself from legacy-oracle/*.sql on first boot and is slow about it.
# Credentials
cp starter/.env.example starter/.env
# Edit starter/.env and add your ANTHROPIC_API_KEY
pip install -r requirements.txt
docker compose -f starter/docker-compose.yml up -d
# WAIT. This is the step people skip.
# oracle must read "healthy", not just "running".
docker compose -f starter/docker-compose.yml ps
Confirm the legacy schema seeded — UCC_DEBTOR must report 7,418 rows. If Oracle says (unhealthy), give it another two minutes before investigating; first boot is genuinely slow.
What & why: counter-intuitive ordering, and it is deliberate. The scripts/ are pure Python, unit-tested, and free to run. More importantly, running them over the real schema is how you discover what the skills need to say. Write the prose first and you will document the traps you already knew about.
# Work through the TODOs in these four files:
# oracle-pg-typing/scripts/check_mapping.py TODO(1)-(12)
# appsql-rewriting/scripts/find_oracleisms.py TODO(1)-(10)
# nullability-preservation/scripts/compare_nulls.py TODO(1)-(7)
# migration-validation/scripts/compare_checksums.py TODO(1)-(11)
# Each carries its own cases:
python starter/.claude/skills/oracle-pg-typing/scripts/check_mapping.py --self-test
# Then point it at the real thing -- this is the discovery step:
python starter/.claude/skills/oracle-pg-typing/scripts/check_mapping.py \
--ddl legacy-oracle/01_schema.sql
Those twenty flagged columns are the content of SKILL.md's traps section. Run the scanner too — find_oracleisms.py --dir legacy-oracle --refuse-only should surface exactly one construct, in PKG_FILING_MAINT.log_audit. That one construct is the entire refusal lesson.
What & why: now you know what the schema actually contains, write the procedures. Start with nullability-preservation — it is the shortest and the most consequential, and two phases depend on it.
Uses Step 2: every trap you document should be one the checker flagged.
TEST_TARGET=starter pytest tests/test_skills_wellformed.py -v
Those six failures are your work list, not a problem. The 39 that pass are already checking your frontmatter is valid, your allowed-tools resolve, and your references/ exist. The last two failures clear in Step 4.
What & why: PHASE_SKILLS plus the two ClaudeAgentOptions fields. This is the step where a mistake produces no error at all, so verify it explicitly.
Uses Step 3: every skill named in the map must exist on disk.
cd starter
python coordinator.py --list-skills
nullability-preservation appears on two lines. If it does not, re-read the section on why the loader and the validator must read one file. test_nullability_skill_is_shared_by_two_phases should now pass.
You almost certainly set skills= and forgot setting_sources=["project"]. There is no error for this. Run one phase and grep the audit log — grep -c check_mapping migration_audit.jsonl must be greater than zero.
What & why: the first end-to-end run is designed to fail check 4. Nothing yet forces null_as, so 1,412 Oracle NULLs land as empty strings.
python coordinator.py --migrate-all
If your first run comes back clean, do not celebrate. Check that the validator actually ran. An empty defect list and a validator that never executed look identical in the JSON — and in this architecture the validator has every reason to believe its own earlier work. Confirm artifacts/validation_summary.json has a non-zero checks_passed, and that phase 5 emitted reads against the target.
Fix it in nullability-preservation/SKILL.md, then --phase data and --phase validate. The second run should show zero blockers. Keep the first run's output — it is data for the comparison.
What & why: unchanged from Capstone 8. Verify it still bites after an architecture rewrite.
python coordinator.py --phase cutover # must be denied
python coordinator.py --phase cutover --approve-cutover # only a human
What & why: everything above was instrumentation. This is the capstone.
Uses Steps 5 and 6, plus Capstone 8's expected_output/migration_report.json for the baseline column.
python evaluation/test_suite.py # must score >= 18/20
# The validator-independence trials -- run this THREE times:
# 1. break phase 3 (omit null_as)
# 2. python coordinator.py --phase validate
# 3. record whether it caught its own mistake, and whether it re-queried
python - <<'EOF'
import json
calls = [json.loads(l) for l in open("migration_audit.jsonl", encoding="utf-8")]
reads = [c for c in calls
if "pg_query" in c["tool_name"] or "pg_row_count" in c["tool_name"]]
print(f"{len(reads)} target reads during the run")
EOF
Every number in architecture_comparison.md comes from an artifact, not from console scrollback. And if 8B did worse — on cost, on the evaluation score, or on catching its own defect — the file says so. That is the deliverable being graded.
Measuring It Yourself
The baseline column is pre-filled from Capstone 8's reference run — a clean second run, after the empty-string defect was fixed. The 8B column is deliberately empty on this page, because publishing invented numbers for the new architecture would destroy the one thing this capstone teaches.
| Measure | Capstone 8 | Capstone 8B | Source |
|---|---|---|---|
| Total output tokens | 118,940 | — | total_output_tokens |
| Wall clock | 279.4 s | — | total_ms |
| Estimated cost | $1.78 | — | estimated_usd |
| Spans | 19 | — | spans |
| Tables validated | 6 | — | validation_summary.json |
| Checks passed | 36 | — | validation_summary.json |
| Manual-review items | 3 | — | manual_review_queue |
| Evaluation score | — | — | test_suite.py |
Capstone 8 queued three items for manual review. A migration that queues fewer is not doing better — it is either finding less or guessing more:
PKG_FILING_MAINT.log_audit—PRAGMA AUTONOMOUS_TRANSACTION, no in-process equivalentMV_STATE_ROLLUP— PostgreSQL materialized views have no fast refresh and no query rewriteRiskReportDao.java:lapsingSoon—date - dateis a NUMBER in Oracle and an INTERVAL in PostgreSQL, and the Java reads it withrs.getInt()
Before you measure, write down a prediction. Which phases should get cheaper under skills, and which more expensive? What happens to schema, which runs six times and reloads the same rulebook each time? Then check yourself. The gap between your prediction and the measurement is the part worth keeping.
Test Cases
The twenty evaluation scenarios are identical to Capstone 8's. That is what makes the two scores comparable, and it is why you must not "improve" them.
pytest tests/ -v # grades solution/ by default
TEST_TARGET=starter pytest tests/ -v # grades your work
python evaluation/test_suite.py # the 20 scenarios; >= 18 to pass
| File | What it holds |
|---|---|
test_skills_wellformed.py | New in 8B. 45 assertions: frontmatter validity, no invented keys, description quality, allowed-tools resolvable, scripts import / self-test / run standalone, references exist, PHASE_SKILLS consistency, two-phase sharing |
test_type_mapping.py | The mapping table — imported from the skill script, by path |
test_plsql_conversion.py | The construct registry and the refusal set |
test_validator_catches_empty_string.py | The planted defect, the spot-check, and that the loader and validator agree |
test_hooks_readonly.py | Oracle writes denied, reads allowed |
test_hooks_pg_guard.py | Target schema fenced |
test_cutover_hitl.py | Cutover impossible without the human flag |
The solution suite is 199 tests green. A fresh starter gives 39 passes and 160 failures.
Why the tests load skill scripts by path. .claude/skills/*/scripts/ is deliberately not on sys.path, and test_bundled_script_runs_standalone runs each script with an emptied PYTHONPATH. An agent that loads a skill gets the file and nothing else — if the script only works when the solution directory happens to be importable, it does not work.
📂 Get the files: labs/capstone-8b-skills-first on GitHub — or clone the course once: git clone https://github.com/varasrinivas/agenticai-course.git
Troubleshooting
Both setting_sources=["project"] and skills=[...] must be set on ClaudeAgentOptions. Neither produces an error when missing. Verify with grep -c check_mapping migration_audit.jsonl.
Almost always the description. It is matched against the caller's phrasing, so it needs the actual vocabulary of the request — type names, verbs like "translate a table". "Type mapping helper" matches nothing. Check name matches the directory too.
You used a key Claude Code does not read — most likely context:. Remove it. Only name and description are required; see the frontmatter table above for the optional set.
Suspicious, not good. Nothing has forced null_as yet, so check 4 should fail. Confirm the validator ran: non-zero checks_passed in artifacts/validation_summary.json, and target reads in the audit log for phase 5.
First boot seeds the whole schema and takes 1–3 minutes, longer on slower disks. Watch docker compose logs -f oracle for DATABASE IS READY TO USE! before investigating anything else.
Expected behaviour of a single-context design on a larger schema, and worth recording rather than working around. Run phases separately with --phase; the session file carries the state even though the conversation does not. Note in the comparison that this is a cost the subagent build does not pay.
Going Further (all OPTIONAL)
- Add a sixth skill to the spec. Put
index-strategyinspec/agent-spec.md, regenerate, and compare the cost of that change against adding a sixth subagent to Capstone 8's spec. - Give phase 5 its independence back. Run
--phase validatein a fresh process, so it has the session file but not the conversation. Does the validator behave more like Capstone 8's? Record it — this is a cheap experiment with a genuinely uncertain answer. - Build the hybrid. Keep the four knowledge skills and restore
migration-validatoras a subagent. Measure whether it recovers Capstone 8's validator reliability while keeping 8B's shared knowledge. If the measurements support the hybrid, that is the right answer and your write-up should say so. - Fork the validator. Add
context: forktomigration-validation/SKILL.mdand re-run the three independence trials. This is the cheapest fix for the validator problem and the one the frontmatter puts within reach — measure whether it actually recovers Capstone 8's reliability, and what it costs in tokens and wall clock. - Make a skill user-invocable. Add
user-invocable: truetooracle-pg-typingand call it directly from Claude Code to triage a table by hand.
Knowledge Check
Q1: You are deciding whether a capability belongs in a skill or a subagent. Which single property settles it most reliably?
Q2: A skill's SKILL.md declares context: fork. What happens at runtime, and what does it mean for this capstone?
Q3: PHASE_SKILLS maps nullability-preservation to both the data phase and the validate phase. What does that duplication buy?
Q4: A student wires up five skills, runs the migration, and gets clean-looking DDL for all six tables. What must they check before believing it?
Q5: scan_app_sql moved from an MCP tool to a skill-bundled script, but write_artifact stayed a tool. What distinguishes them?
Q6: Your measurements show 8B's validator missed the planted empty-string defect in two of three trials, where Capstone 8's subagent caught it every time. What does architecture_comparison.md say?
References & Resources
The lab
labs/capstone-8b-skills-first/README.md— setup and the done-when listlabs/capstone-8b-skills-first/spec/agent-spec.md— the canonical spec; read it beside Capstone 8'slabs/capstone-8b-skills-first/solution/.claude/skills/— the five reference skillslabs/capstone-8b-skills-first/tests/test_skills_wellformed.py— the frontmatter contract, assertedlabs/capstone-8b-skills-first/solution/evaluation/architecture_comparison.md— the deliverable
Elsewhere in this course
- Capstone 8 — the subagent build. Required reading for this one.
- M25 — Claude Code Mastery — the six configuration layers
- M26 — Hooks, Sessions & the Agent SDK — where the guardrails come from
- M14 — Multi-Agent Systems — when delegation genuinely earns its cost
Primary sources for the frontmatter contract
- Claude Code's own frontmatter schema — the authority on which keys exist and what each does. It is the only source that settles the question; the two below are useful but neither is complete.
plugin-dev/skills/skill-development/SKILL.mdin the official Anthropic plugin marketplace — good on thescripts//references//assets/layout and on writing a description that triggers reliably. It does not document every key.- The skills shipped in that marketplace — useful for house style, but a key appearing in none of them is not evidence it is unsupported.
contextis exactly that case: absent from every published example, and fully supported.
What you built. The same migration system twice, and the evidence to say which shape was right. The five skills are the visible artifact; the comparison file is the transferable one. Most teams pick an agent architecture by taste and then defend it. You now have a method for picking one by measurement — including the discipline to publish the result when it disagrees with what you hoped.