description: "Audit DPCM/drum sampling — drum mapping, sample conversion, DMC constraints" argument-hint: "[--focus <dims>]"
DPCM / Drum-Sampling Audit
Audit the DPCM/drum subsystem — how MIDI General-MIDI percussion notes become DPCM
samples, how .wav is converted/packed into NES 1-bit delta data, and how the DMC
channel is driven. This subsystem owns everything under dpcm_sampler/ plus the
DMC-facing edges of the channel pipeline and the CA65 exporter.
Shared protocol (layout, dedup, finding format): .claude/commands/_audit-common.md.
Severity rubric: .claude/commands/_audit-severity.md. Do not restate them.
Hardware claims must cite docs/APU_DMC_REFERENCE.md (register map, the
$C000 + A*64 address formula, the (L*16)+1 length formula, 64-byte address
alignment, the 0–127 output-level clamp, the $FFFF→$8000 address-wrap quirk) and
docs/NES_DMA_REFERENCE.md (DMC DMA steals 3–4 CPU cycles per byte and is the source
of the controller/$2007/$4015 extra-read glitch). Prefer those docs over
re-deriving APU behavior from source.
A dropped or out-of-range drum sample removes audible content, so per
_audit-severity.md it is at least MEDIUM, and HIGH when it silently strips a hit
that the MIDI clearly intended.
Sprint note: a large bug-fixing pass (commits
be4d2bd…8225696) closed most of the issues this audit used to lead with (#64–#74, #140). The dimensions below describe the current (fixed) behavior and ask you to verify the fix holds up under edge cases, rather than re-discovering the original bugs. #76 remains genuinely open (Dimension 6). #75 (thelength_regrounding) was closed on GitHub, then regressed, and was re-fixed on this tree as #295 (commitd392ef6) —_place_samplenow ceils, so Dimension 4's item is fixed (#295); verify it holds.
Parameters (from $ARGUMENTS)
--focus <dims>— comma-separated dimension numbers (e.g.--focus 1,4). Default: all.
Extra Per-Finding Field
- Hardware ref: the
docs/APU_DMC_REFERENCE.md/docs/NES_DMA_REFERENCE.mdsection backing any hardware claim (omit for pure-software findings).
Dimensions
Dimension 1: Drum-note → sample mapping & coverage
The GM-note maps live in dpcm_sampler/drum_engine.py
(DEFAULT_MIDI_DRUM_MAPPING lines 8-56, ADVANCED_MIDI_DRUM_MAPPING lines 58-78).
Mapping resolution is in EnhancedDrumMapper._resolve_dpcm_sample_name and
map_drums (dpcm_sampler/enhanced_drum_mapper.py):
- Fixed (#73/D-10, verify):
DEFAULT_MIDI_DRUM_MAPPINGnow covers the full GM percussion range 35–81 with generic role names (kick/snare/tom/cymbal/etc, not just 7 notes).ADVANCED_MIDI_DRUM_MAPPINGstill only hand-tunes velocity splits for notes 36 and 38; every other note relies on theDEFAULT_MIDI_DRUM_MAPPINGfallback by design (see the trailing comment atdpcm_sampler/drum_engine.py:75-77). Confirm a mid-range note (e.g. 47, mid tom) still resolves to a real sample name and notNone. - Fixed (verify):
_resolve_dpcm_sample_name(dpcm_sampler/enhanced_drum_mapper.py:468-500) no longer stops at the first candidate — it tries the velocity-split name, then the advanced"primary"name, then theDEFAULT_MIDI_DRUM_MAPPINGrole name, in that order, and only returnsNone(→ noise fallback) if none of the three exist inself.sample_index. Verify this cascade actually reaches the index for a name likekick_softthat legitimately isn't present (falls through to"kick"then to the default role name) rather than silently dropping. - #DP-DPCM-12 is CLOSED: the note-off skip in
map_drumsused to reade.get('velocity', 0)only. Real parsed MIDI events fromtracker/parser_fast.pycarryvolume, notvelocity— so the key defaulted to0on every real event and the whole legacy-mode drum-detection loop was dead code on real input, skipping every note as if it were a note-off. It now readsvelocity = e.get('velocity', e.get('volume', 0))before the zero test (enhanced_drum_mapper.py:324-325), matching the defensive dual-key idiom used intracker/track_mapper.pyandnes/emulator_core.py. Verify-the-fix: this bug was invisible to the test suite because the fixtures used syntheticvelocity-keyed events. Any check here must exercise parser output, not hand-built dicts — and the same grep (get('velocity'with novolumefallback) should come back clean acrossdpcm_sampler/. A regression silently drops every drum in legacy mode. _get_advanced_sample(enhanced_drum_mapper.py:452-466) selects byvelocity_ranges; its result is now just one candidate in the fallback chain above rather than a hard commit — confirm a nonexistent velocity-split name no longer kills the whole event, only that one candidate.
Dimension 2: dpcm_index.json schema integrity
The index is loaded in the same three places, still against two different shapes:
EnhancedDrumMapper._load_sample_index(dpcm_sampler/enhanced_drum_mapper.py:279-292) reads the raw index; entries reachDPCMSampleManager.allocate_sample(dpcm_sampler/dpcm_sample_manager.py:15-65, readssample_data.get('length', 1024)at line 34,sample_data.get('data', [])at line 55,sample_data.get('frequency', 33144)at line 58) only through the shared_allocatehelper (enhanced_drum_mapper.py: 263-272), not directly.- The real
dpcm_index.jsonentries only containidandfilename(verify:python -c "import json; print(list(json.load(open('dpcm_index.json')).values())[0])") —dataandfrequencystill always fall back toallocate_sample's defaults on real input; there is no wav data or per-sample frequency anywhere in the index to backfill them from. #341/ DP-DPCM-02 is CLOSED forlength, though: it used to fall back to the same 1024-byte placeholder for every sample, making memory-limit/eviction accounting operate on identical fictional sizes._allocate(enhanced_drum_mapper.py:252-261) now resolves each sample's real on-disk size via_real_sample_size(:229-260, mirrorsgenerate_dpcm_index.resolve_dpcm_sample_path— the same resolution the packer itself uses, cached per sample name inself._sample_size_cacheso a drum reused many times in one song costs oneos.path.getsizecall) and injects it intosample_data['length']before callingallocate_sample, through the one shared call site bothmap_drumscallers use (:350,:416) so a fix to one path can't silently miss the other. Note this only makesDPCMSampleManager's own internal accounting reflect reality — the eviction machinery still has no effect on what actually gets packed into the ROM (packing is driven by frame references viapack_dpcm_into_asm, notsample_manager.active_samples); that remains unchanged and out of scope here. #413/DP-DPCM-07 is CLOSED: the "costs oneos.path.getsizecall" cache guarantee above only held for the successfully-resolved case -- both early-returnNonepaths (nofilenamekey, andresolve_dpcm_sample_pathreturningNone) returned directly without writing toself._sample_size_cache, so a catalog sample that never resolves (a.dmcmoved/deleted from thedmc/root) re-ran the full candidate-path probe (up to 3Path.exists()stats) on every occurrence in the song, not just the first. Both paths now cacheNonebefore returning; the cache-hit check (sample_name in self._sample_size_cache) already worked correctly for a cachedNonesincedict.__contains__doesn't inspect the stored value. Verify-the-fix: confirm_real_sample_sizereturnsNone(falls back to the 1024 placeholder, not a crash) whenresolve_dpcm_sample_pathcan't find the file, and thatdata/frequencyare still correctly understood as permanent placeholders, not a second instance of the same bug. What did change earlier (#70/#71, see Dimension 7) is that the sample manager uses one consistent accounting formula for those remaining defaults instead of two divergent ones, and the now-dead similarity/dedup code that also depended ondatawas removed outright rather than left silently inert. - The packer path moved:
dpcm_sampler/generate_dpcm_index.py:load_dpcm_index_into_packer(lines 38-79) is now called from a single sharedmain.py:pack_dpcm_into_asmhelper (main.py:126-215), not duplicated inline at the two call sites. #380/TD-28 is CLOSED:run_exportandrun_full_pipelinepreviously had copy-pasted, already-diverged DPCM-pack blocks (run_exportnever passedverbose=) — both now just callpack_dpcm_into_asm(frames, asm_path, verbose=...)(main.py:709andmain.py:1250respectively) and format their own status line from the returnedDpcmPackResult. It readssample.get('pitch', 15)(line 75) andsample['filename'](line 66) —pitchis still absent from the shipped index. Confirmid/filenameremain the only keys any consumer can rely on, thatgenerate_dpcm_index(dpcm_sampler/generate_dpcm_index.py:82-102) is still the sole writer (it emits exactlyid+filename), and that no future edit re-forks the two call sites back into separate copies.
Dimension 3: DPCM conversion correctness (1-bit delta)
dpcm_sampler/dpcm_converter.py does WAV→PCM→delta→packed-bits. It remains
orphaned (nothing in the pipeline calls it; generate_dpcm_index scans pre-made
.dmc files directly) but its known assumption bugs were fixed (#342/DP-DPCM-03):
delta_encode(lines 34-42) walks a ±1 step counter, butdpcm_compress(lines 45-66) then re-derives the bit purely fromencoded[i] > encoded[i-1]. Check that the two stages agree and that the bit polarity matches hardware: perdocs/APU_DMC_REFERENCE.md, a1bit adds 2 to the output level and0subtracts 2 (the engine never sets a level, only nudges ±2). A constant-input run encodes all-zero bits ⇒ the level ramps down on playback. Fixed (#342, verify): the start-level assumption wasprev = 0x40(mid-range); the engine's init routine writes$00to$4011before any sample plays (docs/APU_DMC_REFERENCE.md §5), soprevnow starts at0x00to match the real hardware level a played-back sample reconstructs from, instead of producing a startup DC ramp/attack transient that doesn't exist on the actual playback path.- Bit packing order (line 63):
byte |= (bits[i+j] << j)packs LSB-first. Confirm against the DMC shifter order indocs/APU_DMC_REFERENCE.md("Reader → Buffer → Shifter") — wrong bit order plays the sample bit-reversed (audible garbage). - Resampling:
convert_wav_to_unsigned_pcm(line 7) usesnp.interplinear resampling tosample_rate, independent of the DMC rate index written elsewhere (pitch/$4010). Fixed (#342, verify): the default was a fixedsample_rate=8000regardless of the packer's playback rate, so a sample converted then packed at both defaults played ~4x too fast (~2 octaves sharp). Bothdpcm_converter's defaultsample_rateandDpcmPacker.add_sample's defaultpitch_rate=15now derive from the same shared constants (constants.DEFAULT_DMC_RATE_HZ/DEFAULT_DMC_PITCH_RATE, 33144 Hz), so the two defaults can't silently drift apart again — but the module still has no NTSC rate-index table for the other 15pitch_ratevalues; a caller targeting a different rate must pass a matchingsample_rateexplicitly (documented inconvert_wav_to_dmc's docstring). Flag any remaining mismatch between the conversion rate and the playback rate index that would pitch-shift samples.
Dimension 4: Sample size / address / DMC range constraints
dpcm_sampler/dpcm_packer.py computes the $4012/$4013 register values:
- Fixed (#75 → regressed → #295/DP-01) — verify it holds:
_place_sample(lines 77-98) computesdpcm_address_val = (start_address - 0xC000) // 64(line 78) anddpcm_length_val = max(0, (sample['size'] + 14) // 16)(line 88). Verify againstdocs/APU_DMC_REFERENCE.md: address =$C000 + A*64, length =(L*16)+1bytes.length_regnow uses ceiling division ((size + 14) // 16= ceil((size-1)/16)) so asizenot of the form16k+1still gets its full tail read: the engine reads(length_reg*16)+1bytes and the.align 64gap after each sample makes the few extra bytes safe zero-pad, not neighbouring data. This is the flooring bug originally closed as #75 that regressed and was re-fixed here as #295 (commitd392ef6); confirm the ceiling still holds and thatsizestays bounded to 4081 so the 8-bit register can't overflow. - Fixed (verify): the 4081-byte oversized-sample path no longer aborts the pack.
DpcmPacker.add_sample(lines 13-47) now truncates to 4081 bytes whentruncate=True(lines 31-36) instead of always raisingValueError— and the shared call siteload_dpcm_index_into_packer(dpcm_sampler/generate_dpcm_index.py:72-77) always passestruncate=True, so in practice a too-long sample is clamped, not fatal (#68).add_samplestill raises if a caller explicitly passestruncate=False; confirm no current call site does that.dpcm_converter.dpcm_compress(line 66) independently truncates withdmc_bytes[:4081]at conversion time — the two truncation points are consistent (both clamp to 4081), not contradictory. START_ADDR = 0xC000,BANK_SIZE = 8192(lines 5-6), and the 60-bank ceiling (OverflowErrorat line 70). Check.align 64ingenerate_assembly(line 100) keeps every sample 64-byte aligned, and whether anything guards the$FFFF→$8000address-wrap quirk documented indocs/APU_DMC_REFERENCE.md(a sample bleeding past$FFFFplays garbage from$8000).- Fixed, verify no regression (#140): the packer used to receive the entire
1923-sample catalog regardless of what a song used, overflowing the 60-bank
budget and silencing percussion on every drummed song. It's now filtered via
get_dpcm_sample_ids_from_frames(dpcm_sampler/generate_dpcm_index.py:105-117, reads framenote = sample_id + 1) passed assample_ids=intoload_dpcm_index_into_packer, so only samples the exported song actually references get packed.generate_assembly(lines 91-147) now emits sparse lookup tables sized tomax_id + 1with$00placeholders for unpacked ids (lines 123-146) — verify those placeholder slots are provably unreachable (no frame indexes an id that wasn't packed) rather than merely "usually" unreachable.
Dimension 5: DMC level handling & DMA-timing implications
- Fixed, verify no regression (#72/D-09): the DMC output level (
$4011)CMD_DMC_LEVEL($87) emitter path was removed entirely (commit5c032d2) — no stage ever produceddmc_level, so the branch was dead. Confirm it hasn't been re-added; if it is re-added for the$4011non-linear-mixer trick (docs/APU_DMC_REFERENCE.md§6), re-check the level is clamped to the 7-bit 0–127 range$4011accepts before emission. - Silence init:
docs/APU_DMC_REFERENCE.mdsays init should write$00to$4011so the DMC counter doesn't muffle Triangle/Noise via the non-linear mixer. Live on the bytecode path:nes/audio_engine.asm:181writesLDA #$00/STA $4011ataudio_init. #348/NH-HW-1 is CLOSED: the direct-exportinit_music/resetno longer omits it — see/audit-nes-hardwareDimension 4 for the exactexporter/exporter_ca65.pyline numbers across all threeinit_music/resetvariants; nes/mmc3_init.asm, which also had this write, was deleted as dead code, #203. The live DPCM trigger is@write_dpcm(nes/audio_engine.asm, ~line 512), which writes$4010→$4012→$4013then toggles$4015(disable-then-enable-with-DMC), matchingdocs/APU_DMC_REFERENCE.md; re-verify this order is still correct if the trigger routine changes. (The oldseq_cmd_dpcm_play/seq_cmd_instrumentcopies innes/project_builder.pywere removed as dead code — #314/EXP-12; only the livefetch_sequence_byteremains.) - DMA cost:
docs/NES_DMA_REFERENCE.mdnotes each DMC DMA steals 3–4 CPU cycles and a heavy drum catalog fires constantly, delaying OAM DMA and corrupting side-effect reads ($4016/$2007/$4015). This subsystem can't avoid the cost, but flag if the generated engine/docs omit the mandatory DPCM-safe controller-read warning, or if rapid back-to-back triggers are emitted with no awareness of the cycle budget.
Dimension 6: Config robustness (DrumMapperConfig)
dpcm_sampler/enhanced_drum_mapper.py defines DrumPatternConfig (line 12),
SampleManagerConfig (line 53), DrumMapperConfig (line 95) with validate() /
to_file / from_file (line 163):
- Still open (#76/D-13):
from_file(lines 163-191) doesDrumPatternConfig(**config_data.get('pattern_detection', {}))(line 170) and the equivalent forSampleManagerConfig(line 172) — an unexpected/renamed key in the JSON raisesTypeError(not caught; onlyFileNotFoundErrorandjson.JSONDecodeErrorare handled, lines 188-191). No commit in the recent sprint touched this. Note the current blast radius: the CLI--configflag that used to feedmain.py:load_config(main.py:462-466) into this path was intentionally removed (main.py:772-774, #13) because nothing wired it toassign_tracks_to_nes_channels— so todayfrom_fileis reachable only via direct API use (and is exercised bytests/test_drum_mapper_config.py), not the CLI. Still worth fixing/hardening since it's public API surface. validate()(DrumMapperConfig.validate, line 110) enforces weight sums ≈ 1 and ranges.EnhancedDrumMapper.__init__(line 196) callsself.config.validate()unconditionally on whatever config object it holds, so a config passed in afterDrumMapperConfig.from_file(...)is validated before use, so long as it's routed throughEnhancedDrumMapper.__init__— confirm there's no path that uses afrom_file-loaded config directly without going through that constructor.SampleManagerConfig.memory_limitis bounded 1KB–16KB andmax_samples1–64 (lines 77-80), butDPCMSampleManager.__init__defaults (max_samples=16, memory_limit=4096) are declared independently (dpcm_sampler/dpcm_sample_manager.py:5). In the current codebase the only production instantiation site isEnhancedDrumMapper.__init__(enhanced_drum_mapper.py:203-206), which always passes the validated config values through — direct unvalidated construction only happens intests/test_dpcm_sample_manager.py. Low residual risk; flag only if a new production call site constructsDPCMSampleManagerdirectly.
Dimension 7: Sample-manager dedup & lifecycle
dpcm_sampler/dpcm_sample_manager.py (130 lines):
- Fixed, verify (#69/D-06):
allocate_sample(lines 15-65) now assigns ids from a monotonicself._next_idcounter (line 13, incremented at line 51) instead oflen(self.active_samples), so an evicted id is never handed out again to a later allocation. Confirmdpcm_eventsemitted before an eviction still resolve to the sample that was live when they were created (i.e. nothing re-keys already-emitted events). - Fixed, verify (#70/D-07): memory accounting is now unified —
allocate_samplechecksself._get_total_memory() + sample_size > self.memory_limitup front (line 42, accounting for the pending sample before it's inserted) and_get_total_memory(lines 120-130) sumss['metadata']['size'], the same fieldallocate_samplepopulates (line 57) — previously these used two different formulas (one of which,len(data)//8, was always 0 for real index data)._optimize_sample_bank(lines 67-111) now triggers on memory pressure alone, not just the sample-count limit (condition at lines 79-81). Verify a few-but-large-samples scenario (smallmax_samples, largemetadata.size) still evicts correctly. - Removed as dead code, not "fixed to use real data" (#71/D-08):
_find_similar_sample/_calculate_sample_similarityno longer exist in this file — they were deleted (commit5c032d2) rather than repaired, since the underlyingdataarrays are always empty for real index entries (Dimension 2) and the comparison was permanently inert. If similarity-based dedup is reintroduced, it needs real waveform data from the index to do anything useful.
Dimension 8: Channel-pipeline integration (noise vs DMC)
tracker/track_mapper.py:assign_tracks_to_nes_channels(midi_events, dpcm_index_path)
(line 238; called from main.py:run_map and the full pipeline):
- It calls
map_drums_to_dpcmand routes results:nes_tracks['dpcm'] = dpcm_events(line 351, still overwrites any prior dpcm assignment) andnoise_eventsonly land onnoiseif it's still empty (lines 354-355). Fixed, but only partially (#74/D-11): whennoiseis already occupied, the drum noise-fallback events are still discarded (this is physically unavoidable — NES has one Noise channel, perdocs/APU_NOISE_REFERENCE.md) — but this is no longer silent: aprint(...)warning now reports how many events were dropped and why (tracker/track_mapper.py:361-363). Verify the count in the warning matcheslen(noise_events)exactly and that this is the only discard path for these events. - #DP-DPCM-13 is CLOSED — the dpcm slot is no longer a dumping ground. The
leftover-track loop used to route any remaining pitched track into
nes_tracks['dpcm']when that slot was empty ("try noise + dpcm if drum-like or just fill up"). But the dpcm slot's downstream consumers (nes/emulator_core.py, and now_song_has_dpcm_eventsinmain.py) expect{'sample_id', ...}catalog-reference events, not raw pitched note data — so a genuine melodic track landing there collapsed into bogus repeated sample-id-0 triggers with no trace. The loop now routes only drum-named tracks tonoiseand drops anything else with a loud per-track warning (tracker/track_mapper.py:334-343). Verify-the-fix: the two event shapes that share this dict are still structurally distinguishable, and nothing else in the codebase writes non-sample_idevents intones_tracks['dpcm']— a single such write is CRITICAL (it corrupts the channel and now trips thesong buildDPCM rejection for a song that has no real drums). - #256/D-18 is CLOSED (fixed in
7853aa4, predates this sync but the prose here was never updated): the hardcoded default'dpcm_index.json'path used to raise a bareFileNotFoundErrorout of_load_sample_index(enhanced_drum_mapper.py:216-219) uncaught inside the standalonemapsubcommand.run_map(main.py:226-242) now checksPath(dpcm_index_path).exists()up front (honoring--dpcm-indextoo, #13) and exits with a clean[ERROR] DPCM index not found: ...message instead of a raw traceback — matching every other step-by-step guard inmain.py(load_json_stage, #120).run_full_pipeline's outertry/except Exception(main.py:1346-1347,[ERROR] Pipeline failed: ...) is still the backstop for anythingrun_map's own guard doesn't catch, and the pipeline still aborts entirely there rather than degrading to a drumless build — in contrast to the DPCM packer path (main.py:pack_dpcm_into_asm, shared byrun_export/run_full_pipeline, see the packer-path note above), which explicitly handles a missing index file gracefully ("No dpcm_index.json found, skipping"). Verify-the-fix: confirmrun_mapstill exits 1 (not a partial/degraded mapped.json) on a missing index, and that--dpcm-indexpointing at a nonexistent path hits the same guard. - Fixed, verify (#9, #66, #67, #200/D-14, #254):
dpcm_eventscarry{frame, sample_id, velocity}; the frame-generation stage (nes/emulator_core.py:188-235) remaps the raw catalogsample_ids a song actually references to a dense, song-local0..N-1range (dense_id_of, line 218) and encodesnote = min(255, dense_id + 1)(line 227; 0 stays the rest sentinel), emitting adpcm_sample_map(dense_id → catalog_id) side table (lines 232-235) so the pack stage can resolve the real sample files. This replaced both the old 0-95 tone-note clamp (#67) and the rawmin(255, sample_id + 1)that aliased every catalog id ≥ 255 onto note 255 (#200/D-14); theMAX_SAFE_SAMPLE_ID = 254guard that used to pre-empt the remap and route all shipped-catalog drums to noise is also gone (#254). The exporter (exporter/exporter_ca65.py) applies the same byte-ceiling for the'dpcm'channel and the generated trigger routine (nes/project_builder.py) recovers the sample vianote - 1. Confirm the round-trip holds when a song references near 254 distinct drums (dense_id + 1must stay ≤ 255), not merely when a raw catalog id is near 254.
Skeptical checklist
- [ ] Does an unmapped/rare GM drum note (e.g. 47, mid tom) still resolve through
DEFAULT_MIDI_DRUM_MAPPING, or does it now silently fail some other way? - [ ] Does the velocity → primary → default fallback chain in
_resolve_dpcm_sample_nameever return a name that isn't actually inself.sample_index(a logic gap in the finalfor name in candidatesloop)? - [ ] Do
length/data/frequencyever come from the real index, or always defaults — and does that still matter now that the dead similarity/dedup code that depended ondatahas been removed? - [ ] Does
length_reg = max(0, (size+14)//16)(ceiling) read the full sample tail for a sample not sized16k+1(#75 regressed, re-fixed as #295 — verify)? - [ ] Does
DrumMapperConfig.from_filestill raise an uncaughtTypeErroron a stray config key (#76, unfixed)? - [ ] Can an evicted sample id still be reused now that
_next_idis monotonic — or is there an edge case (overflow, reset) that reintroduces reuse? - [ ] Is the memory limit actually enforced end-to-end now that both call sites use
metadata['size'], including the up-frontpending_sizecheck? - [ ] When a real noise track exists, are drum noise-fallback hits still discarded — and is the new warning message accurate?
- [ ] Does the
dpcmchannel's dense-remapped id survive correctly intomusic.asmwhen a song references near 254 distinct drums (dense_id + 1must stay ≤ 255)? - [ ] Does packing only referenced samples (#140) ever leave a frame pointing at an
id that wasn't packed (a
$00placeholder misread as a real sample)?
For every hardware claim, re-open the cited docs/APU_DMC_REFERENCE.md /
docs/NES_DMA_REFERENCE.md line and confirm before reporting. Attempt to disprove
each finding (per _audit-common.md) before including it.
Output
Write the report to: docs/audits/AUDIT_DPCM_<TODAY>.md (YYYY-MM-DD).
Structure:
- Summary — finding counts by severity, the highest-risk drop/round-trip issues.
- Findings — base format from
_audit-common.mdplus theHardware reffield.
Then suggest:
/audit-publish docs/audits/AUDIT_DPCM_<TODAY>.md
</content>TDD Red-Green-Refactor
Testing
Skill that guides Claude through the complete TDD cycle.
Web Accessibility Audit
Testing
Performs a comprehensive web accessibility audit following WCAG standards.
UAT Test Case Generator
Testing
Generates structured and comprehensive user acceptance test cases.