Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/schemas/flag.schema.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Flagged section",
"description": "A user- or agent-raised flag against a specific section: a statutory provision, an EC/EIA condition, a judgment paragraph, or a report clause.",
"description": "A user- or agent-raised flag against a specific section: a statutory provision (kind provision), a judgment or its paragraph (kind case), or an EC/EIA condition or report clause (kind document).",
"type": "object",
"required": ["flag_id", "matter_id", "target", "concern", "raised_by", "status", "created"],
"properties": {
Expand All @@ -11,7 +11,7 @@
"type": "object",
"required": ["kind"],
"properties": {
"kind": { "type": "string", "enum": ["provision", "document"] },
"kind": { "type": "string", "enum": ["provision", "case", "document"] },
"instrument": { "type": "string" },
"section": { "type": "string" },
"catalog_id": { "type": "string" },
Expand Down
1 change: 1 addition & 0 deletions .claude/schemas/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
{ "pattern": "matters/*/flags.jsonl", "schema": "flag.schema.json", "append_only": true, "description": "Append-only log of flagged sections (provisions, EC conditions, report clauses)" },
{ "pattern": "matters/*/findings/*.json", "schema": "finding.schema.json", "description": "Analysis findings, each citing a clause" },
{ "pattern": "matters/*/calc/*.json", "schema": "calc-result.schema.json", "description": "REA/HEA calculator runs" },
{ "pattern": "matters/*/inputs/*.json", "schema": "injury-params.schema.json", "description": "Alternative calculator inputs, one file per labelled run" },
{ "pattern": "matters/*/**", "description": "Chronology, analysis notes and argument drafts" },
{ "pattern": "matters/.gitkeep", "description": "Keeps the empty directory in git" },
{ "pattern": "catalog/**", "description": "Catalog source extracts and notes" },
Expand Down
11 changes: 9 additions & 2 deletions .claude/schemas/matter.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@
"properties": {
"matter_id": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"forum": { "type": "string", "enum": ["ngt", "high_court_writ", "supreme_court", "criminal", "appellate_authority", "scheduled_act_authority", "other", "undetermined"] },
"forum": { "type": "string", "description": "Where the main relief lies", "enum": ["ngt", "high_court_writ", "supreme_court", "criminal", "appellate_authority", "scheduled_act_authority", "other", "undetermined"] },
"other_forums": {
"type": "array",
"description": "Other forums the matter reaches, such as a writ for a point outside NGT Schedule I",
"items": { "type": "string", "enum": ["ngt", "high_court_writ", "supreme_court", "criminal", "appellate_authority", "scheduled_act_authority", "other"] }
},
"description": { "type": "string" },
"created": { "type": "string", "format": "date-time" },
"law_as_at": { "type": "string", "format": "date" }
"law_as_at": { "type": "string", "format": "date" },
"model": { "type": "string", "minLength": 1, "description": "The model that ran the analysis" },
"aranya_version": { "type": "string", "minLength": 1, "description": "The version in package.json when the matter was opened" }
},
"additionalProperties": true
}
18 changes: 18 additions & 0 deletions .claude/scripts/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,30 @@ export function checkFile(relPath, root = process.cwd(), layout = loadLayout(roo
});
}

// A section, para, rule or schedule followed by its number: "s.15(3)", "para 7", "Schedule I", "clause 1.5.1".
// Up to three digits, so a year ("Rules 2016") is not read as a rule number. The number is captured.
// shortcut: a part named without a number, such as "General Condition", is not caught, and a number the entry's summary
// uses for something else (the CPCB scale factor 1.5) passes as a clause; widen the list or tighten the match if runs show it.
const NAMES_A_PART = /\b(?:ss?\.|(?:sections?|paras?|paragraphs?|rules?|regulations?|clauses?|schedules?|appendix|appendices|articles?|arts?|items?)\.?)\s*(\d{1,3}(?:\.\d{1,3})*(?!\d)|[IVXL]+\b|\([a-z0-9]+\))/i;
// True when `text` names `ref` as a whole reference: "1.5.1" in "(1.5.1)", not inside "11.5.1" or "1.5.12".
const quotes = (text = '', ref) => {
for (let i = text.indexOf(ref); i !== -1; i = text.indexOf(ref, i + 1)) {
if (!/[\w.]/.test(text[i - 1] ?? ' ') && !/^(\w|\.\d)/.test(text.slice(i + ref.length, i + ref.length + 2))) return true;
}
return false;
};

// A finding must point at a catalog entry and repeat its source and state, so neither can be made up.
function findingSourceErrors(finding, at, root) {
const catalog = JSON.parse(readFileSync(join(root, 'catalog/provisions.json'), 'utf8'));
const entry = Object.values(catalog).filter(Array.isArray).flat().find(e => e.id === finding.catalog_id);
if (!entry) return [`${at}.catalog_id: "${finding.catalog_id}" is not in catalog/provisions.json`];
const errors = [];
// An instrument entry vouches for the instrument as a whole and for the parts its own summary quotes, nothing more.
const part = catalog.instruments?.includes(entry) && finding.provision.match(NAMES_A_PART);
if (part && !quotes(entry.summary, part[1])) {
errors.push(`${at}.provision: "${finding.provision}" names part of ${entry.id}, which the catalog holds only as a whole instrument. Cite that part's own catalog entry, or raise a flag and ask a human to add one.`);
}
if (finding.source_url !== entry.source_url) errors.push(`${at}.source_url: must match the catalog entry (${entry.source_url})`);
if (finding.verification !== entry.verification) errors.push(`${at}.verification: must match the catalog entry (${entry.verification})`);
return errors;
Expand Down
36 changes: 35 additions & 1 deletion .claude/scripts/tests/guardrails.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const repo = join(scripts, '..', '..');

function run(script, { cwd = repo, input, args = [] } = {}) {
return spawnSync(process.execPath, [join(scripts, script), ...args], {
cwd, input: input && JSON.stringify(input), encoding: 'utf8',
cwd, input: typeof input === 'string' ? input : input && JSON.stringify(input), encoding: 'utf8',
});
}

Expand Down Expand Up @@ -44,6 +44,7 @@ test('layout matches data paths and rejects strays', () => {
assert.equal(matchRule(layout, 'matters/m1/findings/F-0001.json').schema, 'finding.schema.json');
assert.equal(matchRule(layout, 'matters/m1/flags.jsonl').append_only, true);
assert.equal(matchRule(layout, 'catalog/provisions.json').readonly, true);
assert.equal(matchRule(layout, 'matters/m1/inputs/run-2.json').schema, 'injury-params.schema.json');
assert.equal(matchRule(layout, 'src/app.js'), undefined);
});

Expand Down Expand Up @@ -121,6 +122,22 @@ test('hooks find the workspace from a subdirectory and block with exit 2', () =>
rmSync(dir, { recursive: true });
});

test('write-guard fails closed on events it cannot read', () => {
const dir = workspace();
const guard = input => run('write-guard.mjs', { cwd: dir, input }).status;
for (const input of ['', 'not json', 'null', '{}', { tool_name: 'Write' }, { tool_name: 'Write', tool_input: {} }, { tool_name: 'Edit', tool_input: { file_path: '' } }]) {
assert.equal(guard(input), 2, JSON.stringify(input));
}
assert.equal(guard({ tool_name: 'TodoWrite', tool_input: { todos: [] } }), 0, 'a tool that writes no file, caught by a loose matcher');
assert.equal(guard({ tool_name: 'NotebookEdit', tool_input: { notebook_path: 'src/x.ipynb', new_source: 'x' } }), 2);
assert.equal(guard({ tool_name: 'NotebookEdit', tool_input: { notebook_path: 'catalog/provisions.json', new_source: '{}' } }), 2);
assert.equal(guard({ tool_name: 'NotebookEdit', tool_input: { notebook_path: 'matters/m1/flags.jsonl', new_source: '{"c":3}' } }), 2);
assert.equal(guard({ tool_name: 'NotebookEdit', tool_input: { notebook_path: 'docs/x.ipynb', new_source: 'x' } }), 0);
const { hooks } = JSON.parse(readFileSync(join(repo, '.claude/settings.json'), 'utf8'));
assert.match(hooks.PreToolUse[0].matcher, /\bNotebookEdit\b/);
rmSync(dir, { recursive: true });
});

test('write-guard fails closed when the layout is broken', () => {
const dir = workspace();
writeFileSync(join(dir, '.claude/schemas/layout.json'), '{ not json');
Expand All @@ -144,6 +161,22 @@ test('a finding must point at a real catalog entry', () => {
assert.match(check(noEntry), /missing required "catalog_id"/);
assert.match(check({ ...realFinding(), source_url: 'https://indiankanoon.org/doc/1/' }), /source_url: must match/);
assert.match(check({ ...realFinding(), verification: 'unverified' }), /verification: must match/);

const catalog = JSON.parse(readFileSync(join(repo, 'catalog/provisions.json'), 'utf8'));
const water = catalog.instruments.find(e => e.id === 'water-1974');
const onWater = provision => check({ ...realFinding(), catalog_id: water.id, source_url: water.source_url, verification: water.verification, provision });
for (const p of ['s.999', 'Section 33A', 'para 7', 'Schedule I', 'Appendix IV', 'Rule 5(1)', 'clause (k)']) {
assert.match(onWater(p), /holds only as a whole instrument/, p);
}
for (const p of ['The Act as a whole', 'Water (Prevention and Control of Pollution) Act, 1974', 'Hazardous Waste Rules 2016', "the Act's 2010 amendment"]) {
assert.equal(onWater(p), '', p);
}

// An instrument entry that quotes its own clauses vouches for those clauses only.
const cpcb = catalog.instruments.find(e => e.id === 'cpcb-ec-2019');
const onCpcb = provision => check({ ...realFinding(), catalog_id: cpcb.id, source_url: cpcb.source_url, verification: cpcb.verification, provision });
for (const p of ['clause 1.5.1', 'clauses 1.3.2 and 1.5.2', 'Chapter I, instance (f)', 'item (f)']) assert.equal(onCpcb(p), '', p);
for (const p of ['clause 9.9.9', 'para 2.7', 'clause 1.5.13']) assert.match(onCpcb(p), /holds only as a whole instrument/, p);
rmSync(dir, { recursive: true });
});

Expand All @@ -166,6 +199,7 @@ test('validate-on-write exits 2 so the agent sees errors', () => {
const r = run('validate-on-write.mjs', { cwd: dir, input: { tool_input: { file_path: 'matters/m1/findings/F-1.json' } } });
assert.equal(r.status, 2);
assert.match(r.stderr, /not in catalog/);
assert.equal(run('validate-on-write.mjs', { cwd: dir, input: 'not json' }).status, 2, 'an unreadable event must not pass silently');
rmSync(dir, { recursive: true });
});

Expand Down
5 changes: 4 additions & 1 deletion .claude/scripts/validate-on-write.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ process.stdin.on('data', chunk => { raw += chunk; });
process.stdin.on('end', () => {
let event;
try { event = JSON.parse(raw); }
catch { process.exit(0); }
catch {
console.error('Validation skipped: could not read the hook event. Run npm run validate.');
process.exit(2);
}
const filePath = event.tool_input?.file_path;
if (!filePath) process.exit(0);

Expand Down
29 changes: 16 additions & 13 deletions .claude/scripts/write-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
* 4. An agent recording a calculator result as expert_validated
* 5. Content containing secret-shaped strings (API keys, passwords, tokens)
*
* Used as a PreToolUse hook on Write, Edit and MultiEdit.
* Reads the event from stdin, exits 2 to block with a message.
* Used as a PreToolUse hook on Write, Edit, MultiEdit and NotebookEdit.
* Reads the event from stdin, exits 2 to block with a message. An event it
* cannot read blocks too. Shell commands never reach it: see SECURITY.md.
*/

import { readFileSync, existsSync } from 'node:fs';
Expand Down Expand Up @@ -39,9 +40,12 @@ function editsOf(input) {
return [];
}

const FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);

// The file content after the tool runs, so append-only checks see the real result.
// Anything that is not an edit counts as a full replacement, so an unfamiliar tool can't slip past the append-only check.
function contentAfter(before, toolName, input) {
if (toolName === 'Write') return input.content ?? '';
if (toolName !== 'Edit' && toolName !== 'MultiEdit') return input.content ?? input.new_source ?? '';
let text = before;
for (const { old_string: from, new_string: to, replace_all } of editsOf(input)) {
if (replace_all) text = text.split(from).join(to);
Expand All @@ -56,9 +60,12 @@ function contentAfter(before, toolName, input) {
const lf = text => text.replace(/\r\n/g, '\n');

function guard(event) {
const input = event.tool_input ?? {};
const filePath = input.file_path;
if (!filePath) return;
const input = event?.tool_input;
if (typeof event?.tool_name !== 'string' || !input || typeof input !== 'object') block('malformed hook event: no tool_name or tool_input');
const filePath = input.file_path ?? input.notebook_path;
// A matcher like "Write" may also catch a tool that writes no file, such as TodoWrite.
if (filePath === undefined && !FILE_TOOLS.has(event.tool_name)) return;
if (typeof filePath !== 'string' || !filePath) block(`${event.tool_name} event names no file path`);

// The event's cwd follows Claude; the others cover a cwd this platform can't read.
const start = [event.cwd, process.cwd(), process.env.CLAUDE_PROJECT_DIR].find(dir => dir && workspaceRoot(dir));
Expand All @@ -80,7 +87,7 @@ function guard(event) {
}
}

const content = [input.content, ...editsOf(input).map(e => e.new_string)].filter(Boolean).join('\n');
const content = [input.content, input.new_source, ...editsOf(input).map(e => e.new_string)].filter(Boolean).join('\n');
// The file as it would be saved, parsed, so an Edit that swaps only the value or a \u escape is still caught.
let status;
try { status = JSON.parse(contentAfter(existsSync(abs) ? readFileSync(abs, 'utf8') : '', event.tool_name, input)).validation_status; }
Expand All @@ -97,12 +104,8 @@ let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { raw += chunk; });
process.stdin.on('end', () => {
let event;
try { event = JSON.parse(raw); }
catch { process.exit(0); } // not a tool event we understand; nothing to guard

// Only exit 2 blocks, so an unexpected error here must not let the write through.
try { guard(event); }
// Only exit 2 blocks, so unreadable input or an unexpected error must not let the write through.
try { guard(JSON.parse(raw)); }
catch (e) { block(`guard error: ${e.message}`); }
process.exit(0);
});
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/env-case-analysis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ Open and orient a matter. Decide the forum before anything else, map the applica

## Capabilities

1. **Create the matter** — write `matters/{id}/matter.json` (parties, `forum`, dates, `law_as_at`).
1. **Create the matter** — write `matters/{id}/matter.json` (parties, `forum`, any `other_forums`, dates, the catalog's `law_as_at`, `model`, `aranya_version`).
2. **Jurisdiction first** — ask `env-catalog` whether the harm arises under a Schedule I enactment. If not (e.g. pure Wild Life Act 1972 harm), say the NGT may lack jurisdiction and name the alternative forum.
3. **Issues and reliefs** — list the issues; map available reliefs (relief/compensation under `ngt-s15`, restitution of the environment, interim directions).
4. **Findings** — write each point to `matters/{id}/findings/*.json`, citing a provision and carrying its verification state.
4. **Findings** — write each point to `matters/{id}/findings/*.json`, citing a provision and carrying its verification state. A section with no catalog entry of its own gets a flag, even when its Act has one.
5. **Flagging** — append a user- or agent-raised flag to `matters/{id}/flags.jsonl` (append-only); link resolved flags to findings.

## Guardrails
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/env-catalog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Everything traces to an entry in `catalog/provisions.json`, each carrying a `sou
## Capabilities

1. **Lookup** — return a provision/doctrine/case by `id`, instrument, or section, with its summary, citation, `source_url` and `verification` state.
2. **Resolve a flag** — given a flag `target` of `kind: "provision"`, return the catalog node it points at.
2. **Resolve a flag** — given a flag `target` of `kind: "provision"` or `kind: "case"`, return the catalog node it points at, or say the catalog has none.
3. **Jurisdiction check** — a matter founds an NGT case only if a substantial environmental question arises out of a Schedule I enactment (`ngt-schedule-1`). The Wild Life (Protection) Act 1972 is not in Schedule I.
4. **Currency** — surface commencement and amendment flags (e.g. the 2023 amendments to the Forest and Biodiversity Acts; the EIA draft 2020 is not law).

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/hea-rea-calculator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Size compensatory restoration by matching the discounted ecological-service loss
## Capabilities

1. **Gather inputs** per `injury-params.schema.json`: `method` (`HEA` or `REA`), `injured_area` in hectares, `baseline_service` and `injured_service` (0..1), `injury_years`, `recovery_years`, `restoration_gain` (0..1), `restoration_years` and `discount_rate` as a fraction (0.03 means 3%). Optional: `restoration_lag` (years from the start of the injury until restoration delivers service), `restoration_unit_cost` per hectare, `currency`. If any required input is missing, ask for it. Never assume one.
2. **Run** `npm run calc -- <params.json> --out matters/{id}/calc/{run}.json`. The calculator validates the inputs and refuses bad ones; show the user its message rather than adjusting the numbers yourself.
2. **Run** `npm run calc -- <params.json> --out matters/{id}/calc/{run}.json`. The calculator validates the inputs and refuses bad ones; show the user its message rather than adjusting the numbers yourself. When the record contradicts the inputs you were given, run them as given first, then each labelled alternative from its own file in `matters/{id}/inputs/`.
3. **Read the output** — debit and credit in discounted service-hectare-years, the restoration area needed, the scaling ratio, and an optional cost when an expert unit cost is supplied.
4. **Sensitivity**: always present `range` first (area, and cost if there is one). It moves each expert input by `sensitivity_pct` one at a time, so it is not a worst case; say which rows move the result most.

Expand Down
Loading
Loading