-
Notifications
You must be signed in to change notification settings - Fork 55
feat(bots): dispute-archival-bot-first-iteration #2280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # subgraphs for the archived core contract | ||
| export CORE_SUBGRAPH_URL="" | ||
| export DTR_SUBGRAPH_URL="" | ||
|
|
||
| export IPFS_UPLOAD_URL="" | ||
|
|
||
| # required by data mappings | ||
| export GRAPH_API_KEY="" | ||
|
|
||
| # address of dispute archive contract | ||
| export DISPUTE_ARCHIVE_ADDRESS="" | ||
|
|
||
| export ALCHEMY_API_KEY="" | ||
| export PRIVATE_KEY="" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| .env | ||
| !.env.example |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| # dispute-archival-bot | ||
|
|
||
| Archives disputes from an archived KlerosCore contract. | ||
|
|
||
| DisputeArchive Contract : [arbitrum sepolia](https://sepolia.arbiscan.io/address/0x13713cf6D261704A07aB8a44d88CE5d795fdF99d) (For testing, archived the mainnet beta disputes on arbitrum sepolia) | ||
|
|
||
| ### Archived data includes: | ||
|
|
||
| 1. Populated dispute data from `kleros-sdk` | ||
| 2. Evidences for the disputes (Un-filtered) | ||
| 3. Dispute details from Subgraph. Subgraph here is the one that indexed the archived KlerosCore | ||
| 4. Templates for the dispute. These are saved in case the dispute is broken, in which case populated dispute data will be never. Acts as a recovery. | ||
|
|
||
| ### Notes: | ||
|
|
||
| 1. Bot does not constantly monitor the archive core contract, it runs one time and archives any non archived disputes. | ||
| 2. The subgraph query is limited at 1000 entities, for now beta will likely not cross 1000 disputes and 1000 evidences per dispute and 1000 draws per dispute. | ||
|
|
||
| :warning: Subgraph schema was changed between Neo/beta Kleros Core and the new stable versions. This bot queries the older subgraph with old schema for mainnet beta. | ||
|
|
||
| ### Pending | ||
|
|
||
| 1. Logic does not check for ongoing disputes yet, it archives regardless. During stable release, the disputes will all be closed, so should not be an issue. | ||
| 2. Subgraph support pending | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| export const disputeArchiveAbi = [ | ||
| { | ||
| inputs: [], | ||
| stateMutability: "nonpayable", | ||
| type: "constructor", | ||
| }, | ||
| { | ||
| inputs: [], | ||
| name: "CIDCannotBeEmpty", | ||
| type: "error", | ||
| }, | ||
| { | ||
| inputs: [], | ||
| name: "DisputeAlreadyArchived", | ||
| type: "error", | ||
| }, | ||
| { | ||
| inputs: [], | ||
| name: "DisputeNotInArchive", | ||
| type: "error", | ||
| }, | ||
| { | ||
| inputs: [], | ||
| name: "OnlyOwner", | ||
| type: "error", | ||
| }, | ||
| { | ||
| anonymous: false, | ||
| inputs: [ | ||
| { | ||
| indexed: true, | ||
| internalType: "uint256", | ||
| name: "id", | ||
| type: "uint256", | ||
| }, | ||
| { | ||
| indexed: false, | ||
| internalType: "string", | ||
| name: "cid", | ||
| type: "string", | ||
| }, | ||
| ], | ||
| name: "ArchivedDispute", | ||
| type: "event", | ||
| }, | ||
| { | ||
| anonymous: false, | ||
| inputs: [ | ||
| { | ||
| indexed: true, | ||
| internalType: "uint256", | ||
| name: "id", | ||
| type: "uint256", | ||
| }, | ||
| { | ||
| indexed: false, | ||
| internalType: "string", | ||
| name: "cid", | ||
| type: "string", | ||
| }, | ||
| { | ||
| indexed: false, | ||
| internalType: "string", | ||
| name: "reason", | ||
| type: "string", | ||
| }, | ||
| ], | ||
| name: "ArchivedDisputeAmended", | ||
| type: "event", | ||
| }, | ||
| { | ||
| inputs: [ | ||
| { | ||
| internalType: "uint256", | ||
| name: "id", | ||
| type: "uint256", | ||
| }, | ||
| { | ||
| internalType: "string", | ||
| name: "cid", | ||
| type: "string", | ||
| }, | ||
| { | ||
| internalType: "string", | ||
| name: "reason", | ||
| type: "string", | ||
| }, | ||
| ], | ||
| name: "amend", | ||
| outputs: [], | ||
| stateMutability: "nonpayable", | ||
| type: "function", | ||
| }, | ||
| { | ||
| inputs: [ | ||
| { | ||
| internalType: "uint256", | ||
| name: "", | ||
| type: "uint256", | ||
| }, | ||
| ], | ||
| name: "archivedDisputeToCid", | ||
| outputs: [ | ||
| { | ||
| internalType: "string", | ||
| name: "", | ||
| type: "string", | ||
| }, | ||
| ], | ||
| stateMutability: "view", | ||
| type: "function", | ||
| }, | ||
| { | ||
| inputs: [], | ||
| name: "owner", | ||
| outputs: [ | ||
| { | ||
| internalType: "address", | ||
| name: "", | ||
| type: "address", | ||
| }, | ||
| ], | ||
| stateMutability: "view", | ||
| type: "function", | ||
| }, | ||
| { | ||
| inputs: [ | ||
| { | ||
| internalType: "uint256", | ||
| name: "id", | ||
| type: "uint256", | ||
| }, | ||
| { | ||
| internalType: "string", | ||
| name: "cid", | ||
| type: "string", | ||
| }, | ||
| ], | ||
| name: "register", | ||
| outputs: [], | ||
| stateMutability: "nonpayable", | ||
| type: "function", | ||
| }, | ||
| { | ||
| inputs: [ | ||
| { | ||
| internalType: "address", | ||
| name: "newOwner", | ||
| type: "address", | ||
| }, | ||
| ], | ||
| name: "updateOwner", | ||
| outputs: [], | ||
| stateMutability: "nonpayable", | ||
| type: "function", | ||
| }, | ||
| ] as const; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| const variableKeysMap = { | ||
| coreSubgraphUrl: "CORE_SUBGRAPH_URL", | ||
| dtrSubgraphUrl: "DTR_SUBGRAPH_URL", | ||
| graphApiKey: "GRAPH_API_KEY", | ||
| ipfsUploadUrl: "IPFS_UPLOAD_URL", | ||
| disputeArchiveAddress: "DISPUTE_ARCHIVE_ADDRESS", | ||
| alchemyApiKey: "ALCHEMY_API_KEY", | ||
| privateKey: "PRIVATE_KEY", | ||
| } as const; | ||
|
|
||
| type VariableKey = keyof typeof variableKeysMap; | ||
|
|
||
| type EnvConfig = Record<VariableKey, string>; | ||
|
|
||
| export const getEnvConfig = (): EnvConfig => { | ||
| const config: EnvConfig = { | ||
| coreSubgraphUrl: "", | ||
| dtrSubgraphUrl: "", | ||
| graphApiKey: "", | ||
| ipfsUploadUrl: "", | ||
| disputeArchiveAddress: "", | ||
| alchemyApiKey: "", | ||
| privateKey: "", | ||
| }; | ||
|
|
||
| for (const key of Object.keys(variableKeysMap) as VariableKey[]) { | ||
| const envKey = variableKeysMap[key]; | ||
| const value = process.env[envKey]; | ||
|
|
||
| if (!value || value.trim() === "") throw new EnvVariableNotConfiguredError(envKey); | ||
|
|
||
| config[key] = value; | ||
| } | ||
|
|
||
| return config; | ||
| }; | ||
|
|
||
| class EnvVariableNotConfiguredError extends Error { | ||
| constructor(variableKey: string) { | ||
| super(`${variableKey} not configured!`); | ||
|
|
||
| Object.setPrototypeOf(this, EnvVariableNotConfiguredError.prototype); | ||
|
|
||
| this.name = "EnvVariableNotConfiguredError"; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,35 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import "dotenv/config"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { uploadToIpfs } from "./utils/uploadToIpfs.ts"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { fetchDisputeArchiveSnapshot } from "./utils/fetchDisputeArchiveSnapshot.ts"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { fetchDisputeIds } from "./utils/fetchDisputeIds.ts"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { isDisputeArchived, registerCid } from "./utils/contract.ts"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function main() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log("Fetching dispute IDs..."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const disputeIdsUnsorted = await fetchDisputeIds(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Fetched ${disputeIdsUnsorted.length} ids`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const disputeIds = disputeIdsUnsorted.sort((a, b) => Number(a) - Number(b)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Check warning on line 13 in bots/dispute-archival-bot/index.ts
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // skip Ids already archived | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const id of disputeIds) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isArchived = await isDisputeArchived(id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isArchived) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Skipping dispute ${id}. Already archived.`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Archiving dispute ${id} ...`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const snapshot = await fetchDisputeArchiveSnapshot(BigInt(id)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cid = await uploadToIpfs(id, snapshot); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hash = await registerCid(id, cid); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Dispute ${id} archived with cid: ${cid}. Transaction hash: ${hash}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+16
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isolate per-dispute failures so one error doesn’t stop the whole run. A single thrown error currently aborts processing of all subsequent disputes. 🛠️ Suggested loop hardening for (const id of disputeIds) {
- const isArchived = await isDisputeArchived(id);
- if (isArchived) {
- console.log(`Skipping dispute ${id}. Already archived.`);
- continue;
- }
-
- console.log(`Archiving dispute ${id} ...`);
-
- const snapshot = await fetchDisputeArchiveSnapshot(BigInt(id));
-
- const cid = await uploadToIpfs(id, snapshot);
-
- const hash = await registerCid(id, cid);
-
- console.log(`Dispute ${id} archived with cid: ${cid}. Transaction hash: ${hash}`);
+ try {
+ const isArchived = await isDisputeArchived(id);
+ if (isArchived) {
+ console.log(`Skipping dispute ${id}. Already archived.`);
+ continue;
+ }
+
+ console.log(`Archiving dispute ${id} ...`);
+ const snapshot = await fetchDisputeArchiveSnapshot(BigInt(id));
+ const cid = await uploadToIpfs(id, snapshot);
+ const hash = await registerCid(id, cid);
+ console.log(`Dispute ${id} archived with cid: ${cid}. Transaction hash: ${hash}`);
+ } catch (err) {
+ console.error(`Failed to archive dispute ${id}:`, err);
+ continue;
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| main(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Check warning on line 35 in bots/dispute-archival-bot/index.ts
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,17 @@ | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| "name": "dispute-archival-bot", | ||||||||||||||||||||||||||||||
| "packageManager": "yarn@4.10.3", | ||||||||||||||||||||||||||||||
| "type": "module", | ||||||||||||||||||||||||||||||
| "scripts": { | ||||||||||||||||||||||||||||||
| "start": "node index.ts" | ||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
BOT_PKG=$(fd -a '^package\.json$' bots/dispute-archival-bot | head -n1)
BOT_TSCONFIG=$(fd -a '^tsconfig\.json$' bots/dispute-archival-bot | head -n1)
echo "start script:"
jq -r '.scripts.start' "$BOT_PKG"
echo "ts runtime helpers:"
jq -r '.dependencies.tsx // .devDependencies.tsx // "tsx:not-declared"' "$BOT_PKG"
jq -r '.dependencies["ts-node"] // .devDependencies["ts-node"] // "ts-node:not-declared"' "$BOT_PKG"
echo "emitDeclarationOnly:"
jq -r '.compilerOptions.emitDeclarationOnly' "$BOT_TSCONFIG"Repository: kleros/kleros-v2 Length of output: 172 🏁 Script executed: # Check the tsconfig.json configuration
cat bots/dispute-archival-bot/tsconfig.jsonRepository: kleros/kleros-v2 Length of output: 338 🏁 Script executed: # Check if index.ts exists and examine it
ls -la bots/dispute-archival-bot/index.ts 2>&1 || echo "index.ts not found"
# Also check for any compiled versions (index.js, dist/, build/, etc.)
find bots/dispute-archival-bot -maxdepth 2 -type f \( -name "index.js" -o -name "index.mjs" \) 2>&1
find bots/dispute-archival-bot -maxdepth 2 -type d \( -name "dist" -o -name "build" -o -name "out" \) 2>&1Repository: kleros/kleros-v2 Length of output: 144 🏁 Script executed: # Check the full package.json to see the complete context
cat bots/dispute-archival-bot/package.jsonRepository: kleros/kleros-v2 Length of output: 420 🏁 Script executed: # Check the parent tsconfig being extended
cat bots/dispute-archival-bot/node_modules/@kleros/kleros-v2-tsconfig/base20.json 2>/dev/null || echo "Cannot access parent config in sandbox"
# Also verify if there's a build script or any way TypeScript is supposed to be compiled
cd bots/dispute-archival-bot && find . -name "*.json" -o -name "*.ts" -o -name "*.js" | grep -E "(build|compile|tsconfig)" | head -20Repository: kleros/kleros-v2 Length of output: 115 The With Suggested fix "scripts": {
- "start": "node index.ts"
+ "start": "tsx index.ts"
},
@@
"dependencies": {
- "@kleros/kleros-sdk": "workspace:^"
+ "@kleros/kleros-sdk": "workspace:^",
+ "tsx": "^4.20.6"
},🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| "dependencies": { | ||||||||||||||||||||||||||||||
| "@kleros/kleros-sdk": "workspace:^" | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| "devDependencies": { | ||||||||||||||||||||||||||||||
| "@kleros/kleros-v2-tsconfig": "workspace:^", | ||||||||||||||||||||||||||||||
| "@types/node": "^25.6.0", | ||||||||||||||||||||||||||||||
| "dotenv": "^17.4.2", | ||||||||||||||||||||||||||||||
| "viem": "^2.24.1" | ||||||||||||||||||||||||||||||
|
Comment on lines
+11
to
+15
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
BOT_PKG=$(fd -a '^package\.json$' bots/dispute-archival-bot | head -n1)
echo "runtime imports of dotenv/viem in bot sources:"
rg -n --type=ts -C2 'from "dotenv"|from "viem"|import "dotenv/config"' bots/dispute-archival-bot
echo "declared dependency sections:"
jq -r '{dependencies, devDependencies}' "$BOT_PKG"Repository: kleros/kleros-v2 Length of output: 1848
These packages are imported directly at runtime: ✅ Suggested fix "dependencies": {
- "@kleros/kleros-sdk": "workspace:^"
+ "@kleros/kleros-sdk": "workspace:^",
+ "dotenv": "^17.4.2",
+ "viem": "^2.24.1"
},
"devDependencies": {
"@kleros/kleros-v2-tsconfig": "workspace:^",
- "@types/node": "^25.6.0",
- "dotenv": "^17.4.2",
- "viem": "^2.24.1"
+ "@types/node": "^25.6.0"
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "extends": "@kleros/kleros-v2-tsconfig/base20.json", | ||
| "compilerOptions": { | ||
| "declaration": true, | ||
| "emitDeclarationOnly": true, | ||
| "allowImportingTsExtensions": true, | ||
| "types": [ | ||
| "node" | ||
| ], | ||
| "noUnusedLocals": true, | ||
| "noUnusedParameters": true | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { createPublicClient, createWalletClient, type Address } from "viem"; | ||
| import { getEnvConfig } from "../config.ts"; | ||
| import { privateKeyToAccount } from "viem/accounts"; | ||
| import { alchemyTransport } from "./rpc.ts"; | ||
| import { arbitrumSepolia } from "viem/chains"; | ||
| import { disputeArchiveAbi } from "../abi/DisputeArchive.ts"; | ||
|
|
||
| // WARNING: temporary | ||
| export const CHAIN = arbitrumSepolia; | ||
|
|
||
| const getClients = () => { | ||
| const config = getEnvConfig(); | ||
|
|
||
| const account = privateKeyToAccount(`0x${config.privateKey}`); | ||
|
|
||
| return { | ||
| publicClient: createPublicClient({ | ||
| chain: CHAIN, | ||
| transport: alchemyTransport(CHAIN.id), | ||
| }), | ||
| walletClient: createWalletClient({ | ||
| account, | ||
| chain: CHAIN, | ||
| transport: alchemyTransport(CHAIN.id), | ||
| }), | ||
| account, | ||
| }; | ||
| }; | ||
|
|
||
| export async function registerCid(disputeId: string, cid: string) { | ||
| const { publicClient, walletClient, account } = getClients(); | ||
| const config = getEnvConfig(); | ||
|
|
||
| const { request } = await publicClient.simulateContract({ | ||
| account, | ||
| address: config.disputeArchiveAddress as Address, | ||
| abi: disputeArchiveAbi, | ||
| functionName: "register", | ||
| args: [BigInt(disputeId), cid], | ||
| }); | ||
|
|
||
| const hash = await walletClient.writeContract(request); | ||
|
|
||
| const receipt = await publicClient.waitForTransactionReceipt({ | ||
| hash, | ||
| }); | ||
|
|
||
| if (receipt.status !== "success") { | ||
| throw new Error("Transaction failed"); | ||
| } | ||
|
|
||
| return hash; | ||
| } | ||
|
|
||
| // checks if the dispute was already archived | ||
| export async function isDisputeArchived(disputeId: string) { | ||
| const { publicClient } = getClients(); | ||
| const config = getEnvConfig(); | ||
|
|
||
| const res = await publicClient.readContract({ | ||
| address: config.disputeArchiveAddress as Address, | ||
| abi: disputeArchiveAbi, | ||
| functionName: "archivedDisputeToCid", | ||
| args: [BigInt(disputeId)], | ||
| }); | ||
|
|
||
| return res.length !== 0; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix heading hierarchy (
MD001) to avoid markdownlint warnings.Line 7 jumps from
#to###; the section headings should be##under the top title.📘 Suggested markdown fix
Also applies to: 14-14, 21-21
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 7-7: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3
(MD001, heading-increment)
🤖 Prompt for AI Agents