Aturan Domain ERP sebagai Plugin Claude Code: Skill dan Hook

Foto oleh Panamitsu via Wikimedia Commons (CC BY-SA 4.0)
Tentukan dari harga pelanggarannya. Description sebuah skill ada di context setiap sesi, tetapi body-nya baru di-retrieve saat model memilih memuatnya, dan setelah auto-compaction hanya invocation terbaru yang dilampirkan lagi dalam satu budget token bersama, sehingga skill yang lebih lama bisa hilang sepenuhnya. Konvensi seperti branch scoping aman di sana. Aturan yang mode kegagalannya correcting journal seharusnya di CLAUDE.md atau file rules tanpa paths, yang dimuat saat setiap sesi dimulai.
Tambahkan field paths di frontmatter SKILL.md berisi pola glob untuk file yang menjadi topik skill itu. Claude lalu memuat skill tersebut otomatis hanya saat mengerjakan file yang cocok, memakai format glob yang sama dengan path-scoped rules. Description-nya tetap ada di context setiap sesi supaya model tahu skill itu ada, dan itu memang yang diinginkan: halaman detailnya baru datang ketika agent membuka modul yang dibahas.
Tidak. Plugin membundel skill, agent, hook, definisi MCP server, definisi LSP dan satu settings file terbatas, tetapi memory file cakupannya user, project atau managed policy, jadi CLAUDE.md bukan salah satu direktori komponen plugin. Solusi praktisnya adalah menaruh beberapa baris yang harus selalu dimuat di README plugin, lalu setiap repository yang memakainya commit baris itu ke CLAUDE.md miliknya sendiri atau ke file tanpa paths di .claude/rules/.
Hook menerima tool call sebagai JSON di stdin, termasuk tool_name dan tool_input, sehingga bisa membaca path file target dan isi yang diusulkan. Untuk memblokir, keluar dengan exit code 2, yang memblokir call dan menampilkan stderr Anda ke model, atau exit 0 setelah mencetak object hookSpecificOutput dengan permissionDecision bernilai deny plus permissionDecisionReason. Alasannya penting: itulah yang memberi tahu agent cara memperbaiki barisnya alih-alih mencoba ulang.
Hook paling bisa diandalkan di antara ketiga mekanisme, tetapi tidak tanpa syarat. Hook jalan di lifecycle event yang tetap terlepas dari kesimpulan model, dan itu alasannya ia membawa apa pun yang bisa diperiksa secara mekanis. Namun dokumentasinya menyebut bahwa hook command, http atau mcp_tool yang timeout tidak memblokir tool call, dan call itu lanjut lewat permission flow biasa. Batasi guard pada grep dan regex, tanpa network call, supaya momen lambat tidak mengubah gate Anda jadi saran.

Foto oleh Panamitsu via Wikimedia Commons (CC BY-SA 4.0)
Ringkasan Utama
AI agent tidak bisa menyimpulkan invariant ERP yang tidak pernah ditulis siapa pun. Kemas aturan itu sebagai plugin Claude Code dan pasangkan tiap aturan dengan mekanismenya: skill dengan path glob untuk konvensi, aturan di CLAUDE.md yang selalu dimuat untuk apa pun yang harus bertahan melewati compaction, dan hook PreToolUse untuk apa pun yang bisa diperiksa shell script secara deterministik.
Journal itu bertanggal 31 Juli. Hari itu 6 Agustus, Juli sudah closed dan direkonsiliasi empat hari sebelumnya, dan satu jam terakhir agent baru saja menambahkan default posting date yang jatuh ke tanggal dokumen setiap kali periode tidak diisi eksplisit. Kodenya rapi. Test-nya lulus. Reviewer-nya, yaitu saya, tergesa-gesa di hari Jumat, membacanya sebagai hal yang wajar. Jalur default yang baru itu lewat sebuah repository method yang sama sekali tidak membaca tabel periode, jadi entry-nya mendarat di bulan yang sudah closed dan tim finance menemukannya di rekonsiliasi berikutnya.
Masalahnya bukan model. Aturan yang dilanggar itu hanya hidup di kepala dua orang yang membangun modul posting, dan membaca repository class sebanyak apa pun tidak akan mengungkapnya. Tulisan ini soal apa yang saya lakukan setelahnya: mengemas aturan domain ERP kami sebagai plugin Claude Code, lalu bersikap sengaja soal mekanisme mana yang membawa aturan mana. Semua perilaku plugin, skill dan hook di bawah berasal dari dokumentasi Claude Code yang dikutip di akhir. Semua aturan ERP-nya berasal dari sistem yang saya rawat sendiri.
Sebuah codebase ERP membawa invariant yang tidak ada di types, tidak diassert di test, dan tidak terlihat di file yang sedang diedit. Journal di periode yang sudah closed tidak boleh disentuh. Setiap pembacaan data secara implisit terikat pada satu branch. Nomor dokumen tidak boleh punya celah. Hierarki approval punya jalur delegasi, dan approval yang didelegasikan mencatat dua nama, bukan satu. Freight impor dialokasikan ke baris-baris satu shipment memakai basis tertentu yang dipilih seseorang bertahun-tahun lalu dengan alasan tertentu. Tidak satu pun dari itu bisa disimpulkan dari sebuah repository class dan sebuah DTO, jadi agent yang bekerja hanya dari kode akan menghasilkan sesuatu yang compile, lolos review dari siapa pun yang sama tidak tahunya, dan melahirkan koreksi finansial bulan depan.
Di ERP ini lebih menyakitkan dibanding domain lain karena satu alasan: akibat dari salah adalah record tersimpan yang sudah dipakai record lain. Urutan tabel yang salah cukup di-render ulang. Valuation run yang salah berarti dijalankan ulang plus rekonsiliasi. Journal yang salah berarti correcting entry, catatan audit, dan percakapan dengan tim finance yang dibuka dengan kata kenapa. Jadi pertanyaan yang berguna bukan bagaimana mengajari agent lebih banyak, melainkan aturan mana yang boleh cukup disarankan dan aturan mana yang harus dipaksakan.
Plugin Claude Code adalah bundle yang di-version: skill, subagent, hook, definisi MCP server dan definisi LSP, dipasang dan dinyalakan sebagai satu unit. Tiga di antaranya bisa membawa aturan domain, dan bedanya justru di satu hal yang menentukan segalanya di sini, yaitu kapan isinya benar-benar ada di depan model, kalau memang pernah.
| Mekanisme | Kapan isinya sampai ke model | Jadi sebaiknya membawa |
|---|---|---|
| Skill dengan path glob | Description-nya ada di context tiap sesi; body-nya baru dimuat saat agent mengerjakan file yang cocok dengan glob | Konvensi, bentuk query, anatomi perubahan yang benar |
| File rules tanpa path atau CLAUDE.md | Saat sesi dimulai, selalu, dan dibaca ulang dari disk lalu disuntikkan lagi setelah compaction | Dua atau tiga aturan yang tidak boleh bergantung pada retrieval |
| Hook PreToolUse | Di setiap tool call yang cocok, apa pun kesimpulan model | Apa pun yang bisa diputuskan script tanpa pertimbangan |
Baca kolom tengahnya dan penyortirannya hampir selesai sendiri. Aturan yang bikin kesal kalau diabaikan pantas masuk skill. Aturan yang tidak boleh diabaikan justru tidak, karena body sebuah skill itu di-retrieve, dan retrieval adalah keputusan yang diambil model. Aturan yang bisa diputuskan secara mekanis semestinya bukan berupa kalimat sama sekali.
Branch scoping adalah contoh ideal untuk sebuah skill. Ia berlaku di beberapa ratus titik query, mustahil ditebak dari DTO, dan versi salahnya hanyalah bug dan bukan bencana: sebuah report yang menampilkan angka satu branch ke manajer branch lain, yang masih bisa kami tangkap di review. Ia juga panjang. Bentuk query yang benar, alasan predikatnya tinggal di repository dan bukan di controller, serta dua endpoint yang memang sah melintasi branch, bersama-sama jadi satu halaman prosa yang tidak saya mau ada di context setiap sesi, termasuk sesi soal CSS.
# erp-domain/skills/branch-scoping/SKILL.md
---
name: branch-scoping
description: How every read and write in this ERP is scoped to one branch, and which two endpoints may span branches. Use when editing repositories, queries or reports in the posting, inventory or reporting modules.
user-invocable: false
paths:
- "src/modules/posting/**/*.ts"
- "src/modules/inventory/**/*.ts"
- "src/modules/reporting/**/*.ts"
---
Every table carrying a branch_id column is scoped in the repository layer, never
in the controller. The predicate is not optional and it is not the caller's job.
// Wrong: the controller filters, so any caller that forgets sees every branch.
const rows = await this.ledgerRepo.find({ where: { periodId } });
return rows.filter((r) => r.branchId === user.branchId);
// Right: the repository takes the branch from the request context and there is
// no overload without it, so "forgetting" does not typecheck.
const rows = await this.ledgerRepo.findForBranch(ctx.branchId, { periodId });
Two endpoints legitimately span branches: the consolidated trial balance and the
group stock valuation. Both take an explicit branchIds array, both require the
group-finance role, and both are listed in scoping.allowlist.ts. If a third one
is ever needed, it goes in that file in the same commit, not in a comment.Dua field frontmatter yang mengerjakan tugas itu. Field paths membatasi aktivasi otomatis hanya ke file yang cocok dengan glob-nya, memakai format glob yang sama dengan path-scoped rules, jadi skill-nya datang saat agent membuka modul posting atau reporting dan tidak mengganggu di luar itu. Menyetel user-invocable ke false menyembunyikannya dari menu slash, dan itu tepat untuk pengetahuan latar: tidak ada yang mau menjalankan branch scoping sebagai perintah. Description-nya tetap ada di context setiap sesi, jadi model tahu skill itu ada bahkan sebelum membaca satu file pun.
Versi pertama saya menaruh aturan posting period di skill yang sama. Itu salah, dan dokumentasinya menjelaskan sebabnya. Isi skill yang sudah dipanggil dibawa melewati auto-compaction dengan sebuah budget: invocation terakhir dari tiap skill dilampirkan lagi setelah ringkasan, dengan 5,000 token pertama dari masing-masing dipertahankan, total 25,000 token dibagi bersama, dan budget-nya diisi mulai dari skill yang paling terakhir dipanggil. Panggil beberapa skill dalam sesi yang panjang dan yang paling awal hilang sepenuhnya. Aturan yang mode kegagalannya adalah correcting journal tidak bisa hidup di belakang aritmetika seperti itu.
# .claude/rules/posting-periods.md
# No paths: frontmatter, so this loads at launch in every session instead of
# waiting to be retrieved. It is committed to the repository, not shipped in
# the plugin: a plugin cannot carry a CLAUDE.md or a rules file.
- A period has a state in accounting_period: OPEN, CLOSED or LOCKED. Read the
state. Never infer it from the calendar or from today's date.
- Never write a journal whose posting_date falls inside a CLOSED or LOCKED
period. There is no override flag, and adding one is not the fix.
- A correction to a closed month posts into the current OPEN period and carries
reverses_journal_id pointing at the original entry. That is the only route.
- postJournal() is the single entry point. A repository .save() that reaches
journal_entry without going through it is a bug even when its test passes.Jadi aturan periode itu pindah ke tempat yang pemuatannya tanpa syarat. CLAUDE.md dimuat di awal setiap sesi, file di root project dibaca ulang dari disk dan disuntikkan lagi setelah compaction, dan file rules tanpa field paths dimuat saat launch dengan prioritas yang sama. Perhatikan apa yang tidak bisa dilakukan plugin di sini. Plugin mengirim skill, agent, hook, definisi MCP dan LSP, tapi bukan CLAUDE.md, karena memory file itu cakupannya user, project atau managed policy. Jadi README plugin-nya membawa empat baris ini dan repository yang memakainya harus commit sendiri, sebuah pembagian yang agak mengganggu dan belum saya temukan jalan keluarnya.
Saat sebuah aturan tidak dipatuhi, cari tahu dulu apakah ia pernah dimuat sebelum Anda menulisnya ulang. Hook InstructionsLoaded mencatat instruction file mana yang dimuat, kapan, dan mengapa, dan itu cara tercepat membedakan aturan yang kalimatnya buruk dari path glob yang tidak pernah cocok dengan file mana pun.

Penomoran dokumen tanpa celah adalah jenis aturan ketiga: bisa diperiksa secara mekanis. Auditor membaca celah pada rangkaian nomor sebagai dokumen yang dihapus, dan nomor pada tax invoice kami berasal dari rentang yang dialokasikan kantor pajak, jadi satu nomor yang terlewat adalah pertanyaan yang harus dijawab seseorang. Di kode, itu menyusut jadi segelintir pola yang tidak boleh mendekati numbering service, dan yang paling sering adalah membaca sequence di luar transaction yang mengonsumsinya. Meminta model mengingat hal itu jelas lebih buruk daripada menjalankan grep.
// erp-domain/hooks/hooks.json
// A plugin's hooks merge with your user and project hooks once it is enabled.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"if": "Edit(src/modules/numbering/**)",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/guard-numbering.sh",
"timeout": 10,
"statusMessage": "Checking document numbering..."
}
]
}
]
}
}#!/usr/bin/env bash
# erp-domain/scripts/guard-numbering.sh
# A PreToolUse hook receives its input as JSON on stdin.
set -euo pipefail
deny() {
jq -n --arg reason "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $reason
}
}'
exit 0 # exit 0 plus a deny decision. exit 2 also blocks, using stderr as
} # the message, but then the reason is harder to phrase carefully.
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
body=$(printf '%s' "$input" | jq -r '.tool_input.new_string // .tool_input.content // empty')
case "$path" in
*"/modules/numbering/"*) ;;
*) exit 0 ;; # not ours: stay silent, do not slow the edit down
esac
# The sanctioned allocator locks the counter row inside the caller's own
# transaction. A bare nextval hands out a number that a rollback then loses,
# and the gap is what an auditor asks about six months later.
if printf '%s' "$body" | grep -q "nextval("; then
deny "Document numbers must be gapless. Use allocateDocumentNumber(tx, docType), which locks document_sequence FOR UPDATE inside the caller transaction. A bare nextval leaks a number on rollback. See skills/document-numbering in the erp-domain plugin."
fi
exit 0Dua detail yang layak dicontek. Filter if memakai sintaks permission rule, jadi hook-nya terbatas pada modul numbering dan bukan pada setiap edit di repository, sementara matcher tetap membatasinya ke Write dan Edit. Dan guard-nya mengembalikan alasan, bukan sekadar penolakan: exit code 2 memblokir call dan menampilkan stderr ke model, tapi mengeluarkan keputusan deny di stdout memungkinkan Anda mengisi permissionDecisionReason, dan itulah bedanya antara agent yang mengulang baris yang sama tanpa tahu apa-apa dengan agent yang memperbaikinya.
Hook jalan secara deterministik, tapi tidak memblokir tanpa syarat. Dokumentasinya tegas bahwa hook command, http atau mcp_tool yang timeout tidak memblokir tool call: call-nya lanjut lewat permission flow biasa. Batasi guard Anda pada grep dan regex, jangan sekali-kali beri dia network call, dan gate Anda tidak akan berubah diam-diam jadi saran di hari sesuatu melambat.
Seluruhnya hanya sebuah direktori dengan satu manifest, dan tidak ada bagian yang di-generate. Satu kesalahan yang layak disebut lebih dulu adalah menaruh direktori komponen di dalam folder .claude-plugin: hanya plugin.json yang tempatnya di situ, sementara skills, hooks, agents dan scripts semuanya duduk di root plugin. Dokumentasinya menyebut ini sebagai kesalahan umum, yang membuat saya tahu bukan hanya saya yang pernah melakukannya.
erp-domain/
├── .claude-plugin/
│ └── plugin.json # only this file belongs in here
├── skills/
│ ├── branch-scoping/SKILL.md # paths: posting, inventory, reporting
│ ├── approval-delegation/SKILL.md # paths: approval module
│ ├── document-numbering/SKILL.md # paths: numbering module
│ └── landed-cost/SKILL.md # added after the correction below
├── hooks/
│ └── hooks.json # PreToolUse guards
├── scripts/
│ ├── guard-numbering.sh
│ └── guard-closed-period.sh
└── README.md # the rules file a consumer must commit
// erp-domain/.claude-plugin/plugin.json
{
"name": "erp-domain",
"description": "Domain rules for the ERP: posting periods, branch scoping, document numbering, approval delegation, landed cost",
"version": "1.0.0",
"author": { "name": "Matthews Wong" }
}Manifest-nya secara ketat hanya butuh name; sisanya ditemukan di lokasi default, dan version menentukan kapan salinan yang sudah terpasang mengambil perubahan Anda. Pengembangannya berjalan sepenuhnya dari filesystem, dalam empat langkah yang tidak pernah menyentuh marketplace.

Landed cost. Satu shipment impor datang membawa freight, insurance dan duty, dan biaya tingkat shipment itu harus dialokasikan ke baris-barisnya sebelum ada satu baris pun yang punya unit cost. Basis alokasi yang bisa dipertahankan adalah porsi nilai baris, porsi berat dan porsi volume, dan untuk kelas shipment ini aturan kami adalah porsi nilai FOB. Agent-nya mengalokasikan berdasarkan jumlah unit, yang merupakan bacaan paling jelas dari sebarkan freight ke baris-barisnya dan memang yang akan ditulis siapa pun kalau tidak ada yang memberi tahu. Pada satu shipment berisi dua ribu fitting murah dan empat puluh valve mahal, fitting-nya menyerap sebagian besar freight, dan kedua unit cost-nya salah ke arah yang berlawanan. Tidak ada yang gagal, tidak ada test yang merah, dan sebuah margin report salah di akhir bulan. Perbaikannya adalah revaluasi plus correcting entry.
Aturan itu tidak pernah ditulis di mana pun, karena sudah jelas bagi dua orang yang mengimplementasikan modulnya. Dan itulah bentuk semua aturan di tulisan ini: jelas bagi yang memegangnya, tidak terlihat bagi orang lain, dan plugin ini sekadar tempat kami akhirnya menuliskannya. Basis alokasinya sekarang tiga baris di skill landed cost plus sebuah hook yang menolak perubahan apa pun pada fungsi alokasi tanpa fixture test yang menyertainya. Sebelum agent mendekati modul costing atau posting sekarang, saya menjalankan tiga pemeriksaan.
Plugin ini tidak membuat agent lebih pintar. Ia memindahkan tiga keputusan keluar dari pertimbangan model ke dalam file yang punya nama, dan meninggalkan yang keempat pada pertimbangan sampai hal itu menagih saya satu revaluasi. Aturan yang akan saya bawa ke domain apa pun di mana kesalahan tersimpan alih-alih ditampilkan adalah ini: sortir tiap aturan berdasarkan harga pelanggarannya, taruh yang murah di skill yang dimuat lewat glob, yang mahal di tempat yang pemuatannya tanpa syarat, dan yang bisa diperiksa di sebuah hook. Apa pun yang masih bersandar pada pertimbangan model adalah koreksi yang belum Anda bayar.
Sumber