Installs into .claude/skills of the current project.
Are you the author of Bulk Record Jobs?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/shugokifable-bulk-record-jobs)
---
name: bulk-record-jobs
description: Use when a Skyrim task must turn many load-order records into one catalogue, audit, graph, report, or batch patch.
---
# Bulk Record Jobs
## Overview
A bulk record job is any task shaped "many records in, one structured deliverable out": a gear
catalogue, a crafting-graph, a conflict survey, a whole-mod audit, a compatibility-patch rebuild,
a fan-out extraction returning JSON. The houseCARL tool surface has one primitive for every loop
these jobs used to improvise — this skill maps job shapes to those primitives, carries the few
game-level conventions the binary format doesn't (what a crafting-station keyword means, how a
temper recipe differs structurally from a craft recipe), and defines the one canonical deliverable
shape so ten agents extracting in parallel produce one schema, not ten.
Two costs dominate badly-planned bulk jobs, and both are locked in by the first call: per-record
loops (a reverse lookup run 548 times instead of once; ~400 one-element list ops instead of ~40
batched ones) and schema drift (each extractor inventing its own output encoding). Plan the calls
and fix the output shape before touching a record.
## First step — size the job, then fix the shape
Before the first detail read:
1. **Size the scope with a count, not a dump.** `group_by=` on `housecarl_cross_plugin_query`
aggregates ALL matches into a count table in one cheap call:
```
housecarl_cross_plugin_query(type="ARMO", plugins=["SomeMod.esp"], group_by="defined_in")
```
`group_by="winner"` opens a conflict survey ("who wins the contested records, by plugin");
`"type"` profiles a plugin's contents; `"defined_in"` splits definitions from overrides.
The counts tell you whether the job is 40 records or 4,000 before you commit to a per-row plan.
2. **Write the deliverable schema down** (see "The canonical deliverable shape" below) before any
extraction starts — especially before handing work to subagents. The schema is the contract;
extraction fills it.
## Scope truth — two flags that decide what your numbers mean
These two are the proven silent-wrong traps of bulk work. Neither errors when missed; both
corrupt the deliverable.
- **`defined_in=true` — definitions, not touches.** `plugins=["X.esp"]` alone matches every
record X.esp *touches* — its own definitions AND its overrides of other plugins' records. A
catalogue scope ("the items this mod adds") means *defined in*. A replacer or patch plugin can
return dozens of matches on a bare `plugins=` scope, every one an override that double-counts
into your catalogue under the wrong mod. Pass `defined_in=true` whenever the question is "what
does this plugin add"; leave it off only when overrides are genuinely part of the question.
- **`winner_fields=true` — live values, not the scoped plugin's era.** Under a `plugins=` scope,
`fields=` renders the *scoped plugin's own* version of each value — the defining esp's original
armor rating, not the number the game uses after later overrides. The output says so in a note,
but a bulk consumer that never reads notes ships plausible stats from the wrong era. For any
deliverable claiming live stats, pass `winner_fields=true`; the scoped-values default exists for
the other question ("what did THIS plugin set").
## The loop-killer map
Reach for the primitive, never the loop. Each row below was a real improvisation in a real fleet
run before the primitive existed:
| About to… | Use instead |
|---|---|
| Run `references=` once per target record | ONE call — `references=` takes a LIST (OR semantics; each match reports which target(s) it hit via `matches=`) |
| Post-filter FormIDs by `:Plugin.esp` suffix | `defined_in=true` on the query |
| Dump all matches and tally winners by hand | `group_by="winner"` (counts ALL matches, not capped by `limit=`) |
| Read records one call each | `housecarl_batch_record_detail` with the whole FormID list |
| Read full records just to label FormIDs with names | `housecarl_resolve` — one identity line per FormID (type, editorid, name, winner), per-item error isolation |
| Maintain a FormID→name cache by hand | `resolve_names=true` on the read — every FormLink annotated inline, cached server-side across the batch |
| Parse `path = token` text back into JSON | `format="json"` on the read/query — same tokens, stable document, accounting in-band |
| Read two plugin versions and subtract by hand | `housecarl_diff_record(formid, plugin_a, plugin_b)` — either side may be an on-disk, not-enabled plugin |
| Re-type another version's field values into ops | `verb:"CopyFrom"` with `from_plugin=` in `housecarl_bulk_apply` — transplants the field, source may be off-order |
| Add list elements one op at a time | `composes=[...]` — one op appends N elements (`verb:"Add"`) or replaces the whole list (`verb:"ReplaceAll"`; `composes=[]` clears it) |
## Recipe: the catalogue
"Enumerate everything of type T that mod set S adds, with live stats and resolved names."
1. **Size**: `group_by="defined_in"` over the scope (First step).
2. **Enumerate + flat fields in one pass** (scalar fields only — Name, ArmorRating, weights…):
```
housecarl_cross_plugin_query(
type="ARMO", plugins=["ModA.esp", "ModB.esp"], defined_in=true,
fields=["Name", "ArmorRating"], winner_fields=true, format="json")
```
3. **Expand list-bearing rows in a second, batched pass.** `housecarl_cross_plugin_query` has no
`depth=` — a list field renders as a count note (`[list: 5 item(s)]`). Feed the enumerated
FormIDs to `housecarl_batch_record_detail` with `depth=2`, `resolve_names=true`,
`format="json"`: each list element comes back indexed (`Keywords[0]`…) with a structured
`link` sibling (`{resolved, type, editorid, name}`) beside the raw token.
4. **Respect the accounting.** Every JSON document carries `total` / `rendered` / `capped` /
`truncated` / `notes` in-band. `capped=true` means raise `limit=` or page by narrowing scope —
never ship a deliverable whose `total` exceeds its row count without saying so.
## Recipe: the link graph
"For each record, what points at it / what does it point at, as names not hex." Works for any
link-shaped question — crafting recipes, outfits, leveled lists, dialogue — because the reflection
layer knows every FormLink on every type. One graph level = two batched calls:
1. **Reverse edge** (who points at these?): `references=[...]` with the whole target list, bounded
by `type=` — e.g. every recipe involving a list of items is ONE call with `type="COBJ"`.
2. **Contents + identity**: `housecarl_batch_record_detail` on the matches, `depth=` deep enough
for the paths you need, `resolve_names=true` so every link is labeled inline.
Worked example — the crafting graph for one weapon (verified live; vanilla data):
```
housecarl_cross_plugin_query(type="COBJ", references=["012EB7:Skyrim.esm"])
→ TemperWeaponIronSword, RecipeWeaponIronSword, … (every recipe touching Iron Sword, one scan)
housecarl_batch_record_detail(
formids=[…those COBJs…], depth=5, resolve_names=true, format="json",
fields=["EditorID", "Items", "WorkbenchKeyword", "CreatedObject", "CreatedObjectCount", "Conditions"])
```
The load-bearing COBJ paths (only visible at depth ≥ 4–5):
- materials: `Items[i].Item.Item` (the ingredient link) + `Items[i].Item.Count`
- station: `WorkbenchKeyword` (a Keyword link — see conventions below)
- product: `CreatedObject` + `CreatedObjectCount`
- gates: `Conditions[i].Data.Function` names the condition kind; `Conditions[i].Data.Perk`
carries the perk link when the function is `HasPerk`
`resolve_names` annotations resolve against the **live load order**: reading a vanilla body on a
modded order can label a link with a mod's renamed identity for that FormID. That's the truthful
in-game identity — but if the deliverable documents the *original* plugin's era, resolve names
from that era's values instead of assuming the annotation matches the source.
## Crafting-station conventions (what the format doesn't say)
The binary format stores `WorkbenchKeyword` as just a Keyword link; which station it is and what
the recipe *means* there is Creation-Kit convention:
- **Enumerate stations from data, don't hardcode**: crafting-station keywords are vanilla KYWD
records — `housecarl_cross_plugin_query(type="KYWD", editorid_contains="Crafting",
plugins=["Skyrim.esm"], defined_in=true)` lists them live (forge, skyforge, sharpening wheel,
armor table, smelter, tanning rack, cookpot…). Ignore the `WICrafting*` ones — those are
radiant-story event keywords, not stations. Mods add their own station keywords; the same query
without the plugin scope finds them.
- **Craft vs temper is structural, not naming.** A *craft* recipe (forge/smelter/tanning/cookpot):
`CreatedObject` = the item produced from the materials. A *temper* recipe (sharpening wheel for
weapons, armor table for armor): `CreatedObject` = the very item being *improved*, count 1, and
the canonical vanilla condition pair is `EPTemperingItemIsEnchanted != 1` (OR-flagged) +
`HasPerk(<arcane-smithing-class perk>)`. Classify by `WorkbenchKeyword` + this shape.
- **Conventions are conventions — the data can be dirty.** Vanilla itself ships
`TemperWeaponSkyforgeBow` whose `CreatedObject` is a *battleaxe*. Classify from the fields you
read, and let an EditorID that disagrees with the structure raise a flag in the deliverable
rather than win the argument.
- **A recipe that appears at no station** (null/odd `WorkbenchKeyword`, or gated by an
impossible condition) is a common *mod-specific* disablement idiom — whose meaning belongs to
that mod's own skill lane, not here. Report the structure; don't guess the intent.
## Recipe: the patch rebuild
"Re-derive an old compatibility patch against a new version of its target mod." The job is
delta-analysis then batched re-application; the old patch is usually *disabled*, which is fine —
the diff and copy primitives read on-disk plugins that aren't in the load order.
1. **Survey the contest**: `group_by="winner"` over the target mod's records — one call says how
much of the mod is contested and by whom.
2. **Extract the old delta per record**: `housecarl_diff_record(formid, plugin_a="OldPatch.esp",
plugin_b="TargetMod.esp")` — field-level deltas, each labeled by plugin_b's name; `fields=`
scopes the comparison; a truncated read reports itself rather than claiming "identical".
3. **Forward the new baseline** (`housecarl_forward_record`), then **re-apply the still-valid
deltas** with `housecarl_bulk_apply into=` on the forwarded copies — ops target the patch's
own already-forwarded body, not the stale winner (the forward-then-edit precedence both tools'
docs state).
4. **Batch the re-application**: whole-field transplants use `verb:"CopyFrom"` +
`from_plugin="OldPatch.esp"` (works from a disabled plugin on disk); list rebuilds use
`composes=[...]` — N perk placements in one op, `ReplaceAll` + `composes` to rebuild a modeled
list wholesale, `ReplaceAll` + `composes=[]` to empty it. All-or-nothing per call: one bad
element refuses the whole call with per-element reasons.
5. **Verify before enabling**: `housecarl_check_errors` and `housecarl_compact_plugin` accept the
not-yet-enabled patch; `housecarl_diff_record` with the new patch as a pole confirms the
deltas landed.
## Recipe: the fan-out
When the job fans out to subagents, schema drift is the failure mode — N agents told "extract X,
return JSON" will invent N schemas unless the orchestrator pins one. In every subagent prompt:
- **Hand the agent the deliverable schema** (below), filled with the job's field list — not a
prose description of it. Agents hardwire against what you show them.
- Name the exact calls: the scope flags (`defined_in`, `winner_fields`), `format="json"`,
`resolve_names=true`, and the field paths with their depths. A recipe the orchestrator verified
once beats eight agents re-deriving it.
- Require the accounting fields in each agent's return, so a truncated extraction surfaces at
merge time instead of shipping silently incomplete.
## The canonical deliverable shape
One blessed shape for "many records → one document". Use it as the return contract for subagents
and the final deliverable alike; add job-specific keys rather than restructuring.
```json
{
"job": "one line: what this deliverable is",
"scope": {"type": "WEAP", "plugins": ["ModA.esp"], "defined_in": true, "winner_fields": true},
"total": 548,
"complete": true,
"notes": ["tool notes + job caveats carried here, never dropped"],
"records": [
{
"formid": "012EB7:Skyrim.esm",
"type": "Weapon",
"editorid": "IronSword",
"name": "Iron Sword",
"winner": "SomePlugin.esp",
"fields": {"Damage": "7"}
}
]
}
```
The rules that make it a contract:
- **`formid` is the full wire token** (`XXXXXX:Plugin.esp`), verbatim from tool output — never
bare hex (collides across plugins), never reformatted (a token read is a token a write can reuse).
- **Identity is four keys**: `type`, `editorid`, `name`, `winner` — straight from
`housecarl_resolve` / the read header. `name` is `null` where the type has none; don't substitute
the editorid.
- **A resolved link is an object, not a replacement**: `{"formid": "…", "editorid": "…",
"name": "…"}` — keep the token AND the identity; a name-only column can't be queried again.
- **Field values are wire tokens verbatim.** Reformatting (unit conversion, rounding, splitting)
happens in a separate presentation layer, never in the extraction rows.
- **Accounting is mandatory**: `total` (true match count), `complete` (every row present?), and
`notes` travel with the document. A partial deliverable that says so is fine; one that doesn't
is a silent wrong answer.
- **Closed enums for classifications**, declared in the deliverable (e.g. a recipe `kind` of
`craft` / `temper` / `other`) — free-text classification is where eight agents drift eight ways.
For crafting-graph jobs specifically, the blessed per-recipe entry:
```json
{
"recipe": "0DA769:Skyrim.esm",
"kind": "craft",
"station": "CraftingSmithingForge",
"creates": {"formid": "012EB7:Skyrim.esm", "editorid": "IronSword", "name": "Iron Sword", "count": 1},
"materials": [{"formid": "05ACE4:Skyrim.esm", "editorid": "IngotIron", "name": "Iron Ingot", "count": 2}],
"requires_perks": [{"formid": "…", "editorid": "…", "name": "…"}],
"other_conditions": 1
}
```
## Common mistakes
- **Looping a primitive that takes a list.** `references=`, `housecarl_resolve`,
`housecarl_batch_record_detail`, `composes=` — all batch. The single-item call in a loop is
the old world.
- **Shipping scoped values as live stats.** The `plugins=` + `fields=` default renders that
plugin's own era; a catalogue built without `winner_fields=true` is plausibly-wrong everywhere
a later plugin rebalanced.
- **Counting overrides as content.** A bare `plugins=` scope on a patch/replacer returns
overrides; without `defined_in=true` they double-count into the catalogue under the wrong mod.
- **Parsing text output when `format="json"` exists.** Hand-parsing is where schema variance
creeps in; the JSON document carries the same tokens plus the accounting.
- **Dropping the accounting.** `capped`, `truncated`, `rendered < total`, and `notes` exist to be
propagated. A deliverable that silently ignores them claims completeness it doesn't have.
- **Trusting names over structure.** EditorIDs and display names are conventions; classify from
fields (station keyword, `CreatedObject` relationship, condition functions) and flag
disagreements — vanilla itself contains miswired records.
- **Re-typing what `CopyFrom` can transplant.** Reading a value out of one plugin and composing it
back by hand re-introduces transcription risk the primitive exists to remove.
## Sub-topic routing
| The question is really… | Go to |
|---|---|
| "What fields does record type X have / what are the legal enum values?" | `mutagen-reference` |
| "Which armors sit on biped slot N?" | `biped-slot-reference` |
| What a specific mod's keywords/conventions *mean* (any per-mod semantics) | that mod's own skill lane (e.g. a Requiem companion skill), never encoded here |
| One record to read or edit | the tools directly — no bulk planning needed |
| Distributing spells/keywords/items at runtime instead of cataloguing them | `spid-authoring` / `kid-authoring` / `skypatcher-authoring` |
## Notes
- `housecarl_cross_plugin_query` has no `depth=` — list expansion always goes through
`housecarl_batch_record_detail` (or `housecarl_read_record` for one record). Budget the second
call into any list-bearing catalogue plan.
- `where=` accepts FormLink equality against a wire token (e.g.
`"WorkbenchKeyword = 088108:Skyrim.esm"`) — a station filter is one predicate, no post-filtering.
- Off-order (disabled, on-disk) plugins are first-class poles for `housecarl_diff_record`,
sources for `CopyFrom`, and targets for `housecarl_read_plugin_file` / `housecarl_check_errors`
/ `housecarl_compact_plugin` — "the old patch is disabled" blocks nothing.
- Aggregation (`group_by=`) counts ALL matches regardless of `limit=`; only row *rendering* is
capped. Use counts freely for sizing.