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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,25 @@ jobs:
- name: Run test suite
run: npm test

- name: Smoke the real init path
shell: bash
run: |
set -euo pipefail
dry_target="$(mktemp -d)/not-created"
node ./bin/leadline.js init --claude-code --project "$dry_target" --mode advisory --dry-run > "$RUNNER_TEMP/leadline-init-preview.json"
test ! -e "$dry_target"
target="$(mktemp -d)"
node ./bin/leadline.js init --claude-code --project "$target" --mode advisory > "$RUNNER_TEMP/leadline-init-result.json"
node - "$target" <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const target = process.argv[2];
const settings = JSON.parse(fs.readFileSync(path.join(target, '.claude', 'settings.json'), 'utf8'));
const events = ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'];
if (!events.every((event) => settings.hooks?.[event]?.length === 1)) process.exit(1);
const config = fs.readFileSync(path.join(target, '.leadline', 'config.yaml'), 'utf8');
if (!config.includes('mode: advisory')) process.exit(1);
NODE

- name: Run frozen benchmark
run: npm run bench
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ A local-first evidence router and policy engine for coding agents. It classifies
## Commands

```bash
npx github:Nazim22/leadline init --claude-code [--dry-run] # install hooks into a project
npx github:Nazim22/leadline init --claude-code [--mode advisory|enforce] [--dry-run] # preview/install hooks
npx github:Nazim22/leadline route "<prompt>" # see the evidence contract for a prompt
npx github:Nazim22/leadline trace --project . --session <id> # render the decision trace
npm test # full suite
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ Four evidence families, mapped by configured routes (`policy/routes.yaml`) and e
## Get started (60 seconds)

```bash
npx github:Nazim22/leadline init --claude-code --dry-run # advisory: observes, never blocks
npx github:Nazim22/leadline init --claude-code --mode advisory --dry-run # preview only; writes nothing
npx github:Nazim22/leadline init --claude-code --mode advisory # install observation-only hooks
# work normally for a while, then:
npx github:Nazim22/leadline trace --project . --session <id> # what WOULD have been caught
npx github:Nazim22/leadline init --claude-code # flip to enforce
npx github:Nazim22/leadline trace --project . --session <id> # what WOULD have been caught
npx github:Nazim22/leadline init --claude-code --mode enforce # flip to enforce
```

(An npm package — plain `npx leadline` — is coming; the GitHub specifier above works today.)
Expand Down
11 changes: 8 additions & 3 deletions bin/leadline.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const YAML = require('yaml');
const { runBenchmark } = require('../src/benchmark');
const { createPlanner } = require('../src/planner');
const {
handleClaudeHook, installClaudeCode, readTrace, renderDecisionTrace,
handleClaudeHook, installClaudeCode, planClaudeCodeInstall, readTrace, renderDecisionTrace,
} = require('../src/claude-code');

const root = path.join(__dirname, '..');
Expand All @@ -33,7 +33,7 @@ function usage() {
'usage:',
' leadline route <prompt>',
' leadline bench',
' leadline init --claude-code [--project <dir>] [--dry-run]',
' leadline init --claude-code [--project <dir>] [--mode <advisory|enforce>] [--dry-run]',
' leadline hook <UserPromptSubmit|PreToolUse|PostToolUse|Stop> [--project <dir>]',
' leadline trace [--project <dir>] [--session <id>]',
'',
Expand Down Expand Up @@ -79,7 +79,12 @@ function main(argv = process.argv.slice(2), io = {}) {
if (command === 'init') {
if (!args.includes('--claude-code')) { stderr.write('leadline init requires --claude-code\n'); return 2; }
const projectDir = path.resolve(takeOption(args, '--project', cwd));
const result = installClaudeCode({ projectDir, packageRoot: root, mode: args.includes('--dry-run') ? 'advisory' : 'enforce' });
const mode = takeOption(args, '--mode', 'enforce');
if (!['enforce', 'advisory'].includes(mode)) throw new TypeError('mode must be advisory or enforce');
const options = { projectDir, packageRoot: root, mode };
const result = args.includes('--dry-run')
? planClaudeCodeInstall(options)
: installClaudeCode(options);
stdout.write(`${JSON.stringify(result)}\n`);
return 0;
}
Expand Down
96 changes: 83 additions & 13 deletions src/claude-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const path = require('node:path');
const YAML = require('yaml');
const { createPlanner } = require('./planner');
const {
createContractEngine, loadPolicyPacks, negotiateAdapterMode, renderDecisionTrace,
createContractEngine, loadPolicyPacks, negotiateAdapterMode, renderDecisionTrace, validateTraceRow,
} = require('./contract-engine');

const HOOK_EVENTS = Object.freeze(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop']);
Expand Down Expand Up @@ -57,6 +57,7 @@ function traceFile(projectDir, sessionId) {

function appendTrace(file, trace) {
if (!trace) return;
validateTraceRow(trace);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, `${JSON.stringify(trace)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'a' });
}
Expand All @@ -69,6 +70,22 @@ function loadMode(projectDir) {
return config.mode;
}

function correctionRouteAvailable(projectDir, route) {
const configured = String(route || '');
if (!configured.toLocaleLowerCase().startsWith('read ')) return true;
const target = configured.slice(5).trim();
if (!target) return false;
const absolute = path.resolve(projectDir, target);
const relative = path.relative(projectDir, absolute);
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
try {
return fs.statSync(absolute).isFile();
} catch (error) {
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
throw error;
}
}

function loadRuntime(projectDir, packageRoot) {
const packDirectory = fs.existsSync(path.join(projectDir, '.leadline', 'packs'))
? path.join(projectDir, '.leadline', 'packs')
Expand All @@ -81,7 +98,10 @@ function loadRuntime(projectDir, packageRoot) {
directory: packDirectory,
schemaPath: path.join(packageRoot, 'schema', 'policy-pack.schema.json'),
});
return createContractEngine({ planner, packs, mode: loadMode(projectDir) });
return createContractEngine({
planner, packs, mode: loadMode(projectDir),
routeAvailable: (route) => correctionRouteAvailable(projectDir, route),
});
}

function canonicalJson(value) {
Expand Down Expand Up @@ -127,20 +147,49 @@ function emptyState(engine, input) {
}).state;
}

function stripInjectedSpans(prompt) {
return String(prompt || '')
.replace(/<task-notification(?:\s[^>]*)?>[\s\S]*?(?:<\/task-notification>|$)/giu, '\n')
.replace(/<system-reminder(?:\s[^>]*)?>[\s\S]*?(?:<\/system-reminder>|$)/giu, '\n')
.replace(/\[LEADLINE\][\s\S]*?(?:\[\/LEADLINE\]|$)/giu, '\n');
}

function boundedField(value, maximumBytes) {
const text = String(value || '').replace(/\s+/gu, ' ').trim();
if (Buffer.byteLength(text, 'utf8') <= maximumBytes) return text;
const words = text.split(' ');
let bounded = '';
for (const word of words) {
const candidate = bounded ? `${bounded} ${word}` : word;
if (Buffer.byteLength(`${candidate} …`, 'utf8') > maximumBytes) break;
bounded = candidate;
}
return bounded ? `${bounded} …` : '[overlong token omitted]';
}

function renderInjection(contract) {
if (!contract.steps.length) return '';
const unmatched = contract.unmatched_clauses.length
? `${contract.unmatched_clauses.length} (${[...new Set(contract.unmatched_clauses.map((clause) => clause.reason))].join(', ')})`
: 'none';
const lines = [
'[LEADLINE]',
`contract: ${contract.contract_id}`,
`complete: ${contract.complete}`,
`unmatched: ${contract.unmatched_clauses.length
? contract.unmatched_clauses.map((clause) => `${clause.index}:${clause.text}`).join(' | ')
: 'none'}`,
`unmatched: ${boundedField(unmatched, 160)}`,
];
contract.steps.slice(0, 4).forEach((step, index) => {
lines.push(`obligation_${index + 1}: ${step.evidence_target.question} | family: ${step.need} | route: ${step.provider} | satisfies: real_result_only`);
});
if (contract.steps.length > 4) lines.push(`obligations_omitted: ${contract.steps.length - 4}`);
let omitted = Math.max(0, contract.steps.length - 4);
for (const [index, step] of contract.steps.slice(0, 4).entries()) {
const subject = boundedField(step.evidence_target.subject, 120);
const route = boundedField(step.provider, 80);
const line = `obligation_${index + 1}: ${subject} | family: ${step.need} | route: ${route} | satisfies: real_result_only`;
if (Buffer.byteLength([...lines, line, '[/LEADLINE]'].join('\n'), 'utf8') <= 1200) lines.push(line);
else omitted += 1;
}
if (omitted > 0) {
const line = `obligations_omitted: ${omitted}`;
if (Buffer.byteLength([...lines, line, '[/LEADLINE]'].join('\n'), 'utf8') <= 1200) lines.push(line);
}
lines.push('[/LEADLINE]');
return lines.slice(0, 10).join('\n');
}
Expand Down Expand Up @@ -244,7 +293,8 @@ function handleClaudeHookLocked(input, { projectDir, packageRoot }) {
const traces = traceFile(root, input.session_id);

if (input.hook_event_name === 'UserPromptSubmit') {
const prompt = typeof input.prompt === 'string' ? input.prompt : '';
const rawPrompt = typeof input.prompt === 'string' ? input.prompt : '';
const prompt = stripInjectedSpans(rawPrompt).trim();
const turnId = `turn-${crypto.createHash('sha256').update(`${input.session_id || ''}\0${prompt}`).digest('hex').slice(0, 16)}`;
const state = engine.begin(prompt, { sessionId: String(input.session_id || 'unknown'), turnId }).state;
writeJsonAtomic(statePath, state);
Expand Down Expand Up @@ -291,7 +341,10 @@ function handleClaudeHookLocked(input, { projectDir, packageRoot }) {
obligation: 'bind tool result to its accepted call', family: 'unknown',
attempted: String(input.tool_name || 'unknown'), verdict: 'CORRECT',
corrected_route: 'matching PreToolUse/PostToolUse tool_use_id and arguments',
receipt: { satisfied: false, failure: 'unbound_tool_result', call_id: binding?.call_id || null },
receipt: {
rule_id: 'receipt-binding', satisfied: false,
failure: 'unbound_tool_result', call_id: binding?.call_id || null,
},
});
if (engine.mode === 'advisory') {
return { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: message } };
Expand Down Expand Up @@ -333,9 +386,25 @@ function managedHookCommand(packageRoot, event) {
return `${shellQuote(process.execPath)} ${shellQuote(cli)} hook ${event} --project "\${CLAUDE_PROJECT_DIR}"`;
}

function installClaudeCode({ projectDir, packageRoot = path.join(__dirname, '..'), mode = 'enforce' } = {}) {
function planClaudeCodeInstall({ projectDir, packageRoot = path.join(__dirname, '..'), mode = 'enforce' } = {}) {
if (!['enforce', 'advisory'].includes(mode)) throw new TypeError('mode must be enforce or advisory');
const root = path.resolve(projectDir || process.cwd());
const packFiles = fs.readdirSync(path.join(packageRoot, 'policy', 'packs'))
.filter((name) => name.endsWith('.yaml')).sort();
return {
project: root, adapter: 'claude-code', mode, dry_run: true, hooks: [...HOOK_EVENTS],
writes: [
path.join(root, '.claude', 'settings.json'),
...packFiles.map((file) => path.join(root, '.leadline', 'packs', file)),
path.join(root, '.leadline', 'config.yaml'),
path.join(root, '.leadline', '.gitignore'),
],
};
}

function installClaudeCode({ projectDir, packageRoot = path.join(__dirname, '..'), mode = 'enforce' } = {}) {
const plan = planClaudeCodeInstall({ projectDir, packageRoot, mode });
const root = plan.project;
const claudeDir = path.join(root, '.claude');
const leadlineDir = path.join(root, '.leadline');
const targetPackDir = path.join(leadlineDir, 'packs');
Expand Down Expand Up @@ -380,5 +449,6 @@ function readTrace(projectDir, sessionId) {

module.exports = {
CLAUDE_CODE_CAPABILITIES, HOOK_EVENTS, _withStateLock: withStateLock,
handleClaudeHook, installClaudeCode, readTrace, renderDecisionTrace, renderInjection,
handleClaudeHook, installClaudeCode, planClaudeCodeInstall, readTrace,
renderDecisionTrace, renderInjection, stripInjectedSpans,
};
Loading
Loading