Skip to main content
BlogERP

Aturan Domain ERP sebagai Plugin Claude Code: Skill dan Hook

Agent tidak bisa menyimpulkan bahwa journal di periode closed tak boleh disentuh, bahwa tiap query terikat satu branch, atau bahwa nomor dokumen harus tanpa celah. Ini cara saya mengemasnya jadi plugin Claude Code.

Matthews Wong

Software Engineer

September 202612 menit baca
Aturan Domain ERP sebagai Plugin Claude Code: Skill dan Hook

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.

Aturannya nyata, berkonsekuensi, dan tidak tertulis di mana pun

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.

Pasangkan aturan dengan mekanisme berdasarkan kapan ia dimuat

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.

MekanismeKapan isinya sampai ke modelJadi sebaiknya membawa
Skill dengan path globDescription-nya ada di context tiap sesi; body-nya baru dimuat saat agent mengerjakan file yang cocok dengan globKonvensi, bentuk query, anatomi perubahan yang benar
File rules tanpa path atau CLAUDE.mdSaat sesi dimulai, selalu, dan dibaca ulang dari disk lalu disuntikkan lagi setelah compactionDua atau tiga aturan yang tidak boleh bergantung pada retrieval
Hook PreToolUseDi setiap tool call yang cocok, apa pun kesimpulan modelApa 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.

Skill branch scoping, dimuat saat agent menyentuh modulnya

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.

Aturan periode closed tidak boleh menunggu di-retrieve

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.

Lima band bertumpuk yang menyortir aturan ERP ke mekanisme Claude Code, dari branch scoping yang dimuat saat glob cocok, lewat aturan posting period yang selalu dimuat dan hook penomoran, sampai alokasi landed cost yang diserahkan pada pertimbangan model.
Baca tag-nya sebelum label-nya, karena tag itulah argumennya. Hanya band paling bawah yang dibiarkan dipikirkan model sendiri, dan hanya itu yang berujung pada correcting entry.

Hook adalah satu-satunya lapisan yang jalan setuju atau tidak

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 0

Dua 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.

Apa yang sebenarnya ada di dalam plugin ini

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.

  1. Jalankan claude dengan flag plugin-dir yang menunjuk ke direktori itu. Plugin-nya dimuat untuk satu sesi tanpa dipasang, dan menang atas salinan terpasang dengan nama sama, jadi Anda bisa menguji perubahan pada sesuatu yang sudah dipakai.
  2. Ubah sebuah skill atau hook, lalu jalankan perintah reload-plugins untuk mengambil perubahannya tanpa merestart sesi.
  3. Picu event yang dicocokkan hook-nya, di sini berarti meminta sebuah edit di modul numbering, lalu baca debug log untuk memastikan hook mana yang cocok dan bagaimana exit-nya. Hook yang diam-diam tidak pernah cocok terlihat persis seperti hook yang lolos.
  4. Jalankan claude plugin validate pada direktori itu sebelum dibagikan. Ia mencetak Validation passed, atau Validation passed with warnings, dan flag strict mengubah warning tersebut menjadi error.
Tiga panel berdampingan yang membandingkan biaya perubahan yang salah di layar dan report, di stock dan costing, serta di modul ledger dan pajak, berakhir pada restatement.
Panel ketiga adalah alasan plugin ERP layak dibangun sama sekali. Tidak ada proses jalan ulang yang membatalkan journal yang sudah diposting, jadi guard-nya harus duduk sebelum edit, bukan di review sesudahnya.

Aturan yang saya serahkan pada pertimbangan, dan harganya

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.

  • Sebutkan invariant-nya dengan lantang sebelum task dimulai. Kalau saya tidak bisa menyatakannya dalam satu kalimat, agent tidak punya peluang menyimpulkannya, dan begitu juga engineer berikutnya.
  • Tentukan tempatnya dengan bertanya berapa harga pelanggarannya: satu render ulang, satu proses jalan ulang, atau satu restatement. Jawaban ketiga tidak pernah masuk skill.
  • Untuk apa pun yang masuk kategori ketiga, tulis pemeriksaannya sebagai script dulu dan prosanya belakangan. Prosanya menjelaskan script-nya; script-nya bagian yang benar-benar menahan.

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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Aturan bisnis ERP sebaiknya masuk skill Claude Code atau CLAUDE.md?

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.

Bagaimana caranya agar sebuah skill tidak dimuat di setiap sesi?

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.

Apakah plugin Claude Code bisa mengirim file CLAUDE.md berisi aturannya?

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/.

Bagaimana hook PreToolUse memblokir edit di modul ERP yang dilindungi?

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.

Apakah hook menjadi guardrail yang pasti untuk kode finansial?

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.