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
17 changes: 14 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to Kernel will be documented in this file.

This project follows semantic versioning once public releases begin.

## 0.2.0

### Added

- Repo intelligence (0.4): v2 map schemas, CODEOWNERS, monorepo workspaces, Makefile/justfile command detection, config-aware risk maps.
- Policy engine (0.5): `policy-gate.yaml`, `kernel policy check`, verification escalation, CI policy validation.
- GitHub context (0.6): `kernel context pr` and `kernel context issue` read-only providers with optional `.agent/context` cache.
- Optional `context.github` config block for owner/repo resolution.
- Public sync workflow now verifies `kernel policy check --ci` on exported tree.

### Notes

- Private `kernel-skills` remains `"private": true`; npm publication uses the public `mattbaconz/kernel` repository.

## 0.1.0 - Unreleased

### Added
Expand Down Expand Up @@ -32,9 +46,6 @@ This project follows semantic versioning once public releases begin.
- Adapter compile deduplication for shared output paths across ADEs.
- Expanded eval fixtures for context-router, risk-map, diff-surgeon, and repo-cartographer.
- Updated CLI Command Spec for implemented commands and flags.
- Repo intelligence (0.4): v2 map schemas, CODEOWNERS, monorepo workspaces, Makefile/justfile command detection, config-aware risk maps.
- Policy engine (0.5): `policy-gate.yaml`, `kernel policy check`, verification escalation, CI policy validation.
- `kernel init` seeds `.agent/policies/policy-gate.yaml`.

### Notes

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mattbaconz/kernel",
"version": "0.1.0",
"version": "0.2.0",
"description": "Kernel CLI: a repo-local quality system and portable operating layer for coding agents.",
"type": "module",
"private": true,
Expand Down
87 changes: 86 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ import {
formatPolicyCheckJsonResult,
formatPolicyCheckResult
} from '../core/policy/check.js';
import {
fetchContext,
formatContextJsonResult,
formatContextResult
} from '../core/context/fetch.js';
import { KernelContextError } from '../core/context/types.js';
import { createCliJsonErrorEnvelope, formatCliJsonErrorEnvelope } from './json-errors.js';

export function createKernelProgram(): Command {
Expand All @@ -44,7 +50,7 @@ export function createKernelProgram(): Command {
program
.name('kernel')
.description('Repo-local quality system and portable operating layer for coding agents.')
.version('0.1.0');
.version('0.2.0');

program
.command('init')
Expand Down Expand Up @@ -175,6 +181,85 @@ export function createKernelProgram(): Command {
}
});

const context = program.command('context').description('Fetch read-only external context for agents.');
context
.command('pr')
.description('Fetch GitHub pull request context.')
.option('--number <number>', 'pull request number', (value) => Number.parseInt(value, 10))
.option('--current', 'infer pull request from current branch or gh pr view')
.option('--dry-run', 'fetch context without writing .agent/context cache')
.option('--json', 'print machine-readable JSON')
.action(async (options: { number?: number; current?: boolean; dryRun?: boolean; json?: boolean }) => {
try {
const result = await fetchContext({
kind: 'pr',
number: options.number,
current: Boolean(options.current),
dryRun: Boolean(options.dryRun)
});

if (options.json) {
process.stdout.write(formatContextJsonResult(result));
} else {
process.stdout.write(`${formatContextResult(result)}\n`);
}

if (result.status === 'error') {
process.exitCode = 1;
}
} catch (error) {
if (error instanceof KernelContextError && !options.json) {
console.error(error.message);
process.exitCode = 1;
return;
}

if (writeJsonErrorEnvelope('context pr', Boolean(options.json), error)) {
return;
}

throw error;
}
});

context
.command('issue')
.description('Fetch GitHub issue context.')
.option('--number <number>', 'issue number', (value) => Number.parseInt(value, 10))
.option('--dry-run', 'fetch context without writing .agent/context cache')
.option('--json', 'print machine-readable JSON')
.action(async (options: { number?: number; dryRun?: boolean; json?: boolean }) => {
try {
const result = await fetchContext({
kind: 'issue',
number: options.number,
dryRun: Boolean(options.dryRun)
});

if (options.json) {
process.stdout.write(formatContextJsonResult(result));
} else {
process.stdout.write(`${formatContextResult(result)}\n`);
}

if (result.status === 'error') {
process.exitCode = 1;
}
} catch (error) {
if (error instanceof KernelContextError && !options.json) {
console.error(error.message);
process.exitCode = 1;
return;
}

if (writeJsonErrorEnvelope('context issue', Boolean(options.json), error)) {
return;
}

throw error;
}
});

program
.command('compile')
.description('Compile Kernel canonical source into ADE-specific adapter files.')
Expand Down
12 changes: 12 additions & 0 deletions src/cli/json-errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { KernelConfigError } from '../core/config.js';
import { KernelContextError } from '../core/context/types.js';
import { KernelEvalRunnerError } from '../core/eval.js';
import { KernelFileExistsError } from '../core/fs.js';
import { formatKernelJsonResult } from '../core/json-output.js';
Expand Down Expand Up @@ -30,6 +31,17 @@ export function createCliJsonErrorEnvelope(command: string, error: unknown): Cli
};
}

if (error instanceof KernelContextError) {
return {
status: 'error',
error: {
code: error.code,
command,
message: error.message
}
};
}

if (error instanceof KernelConfigError) {
return {
status: 'error',
Expand Down
15 changes: 14 additions & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const defaultConfigValues = {
},
maps: {
include_codeowners: true
},
context: {
github: {}
}
} as const;

Expand Down Expand Up @@ -116,7 +119,17 @@ export const kernelConfigSchema = z
.object({
include_codeowners: z.boolean().default(defaultConfigValues.maps.include_codeowners)
})
.default(defaultConfigValues.maps)
.default(defaultConfigValues.maps),
context: z
.object({
github: z
.object({
owner: z.string().min(1).optional(),
repo: z.string().min(1).optional()
})
.default(defaultConfigValues.context.github)
})
.default(defaultConfigValues.context)
})
.strict();

Expand Down
113 changes: 113 additions & 0 deletions src/core/context/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

import { loadKernelConfig } from '../config.js';
import { formatKernelJsonResult } from '../json-output.js';
import {
createGitHubApiClient,
resolveCurrentPullRequestNumber,
resolveGitHubRepo,
type GitHubApiClient
} from './github.js';
import { fetchIssueContext } from './issue.js';
import { fetchPrContext } from './pr.js';
import type { ContextResult, IssueContextData, PrContextData } from './types.js';
import { KernelContextError } from './types.js';

export interface ContextFetchOptions {
kind: 'pr' | 'issue';
number?: number;
current?: boolean;
rootDir?: string;
dryRun?: boolean;
apiClient?: GitHubApiClient;
}

export async function fetchContext(options: ContextFetchOptions): Promise<ContextResult<PrContextData | IssueContextData>> {
const rootDir = options.rootDir ?? process.cwd();
const config = await loadKernelConfig(rootDir);
const repo = await resolveGitHubRepo({ rootDir, config });
const apiClient = createGitHubApiClient({ apiClient: options.apiClient });

let number = options.number;
if (options.current || number === undefined) {
if (options.kind !== 'pr') {
throw new KernelContextError('Issue context requires --number.', 'invalid_context_request');
}

number = await resolveCurrentPullRequestNumber(rootDir, repo, apiClient);
}

if (number === undefined) {
throw new KernelContextError(`${options.kind} context requires --number or --current.`, 'invalid_context_request');
}

const result =
options.kind === 'pr'
? await fetchPrContext({ repo, number, apiClient })
: await fetchIssueContext({ repo, number, apiClient });

if (result.status === 'ok' && result.data && !options.dryRun) {
result.cachedPath = await writeContextCache(rootDir, options.kind, number, result.data, config.canonical.agent_dir);
}

return result;
}

export function formatContextResult(result: ContextResult<PrContextData | IssueContextData>): string {
if (result.status === 'error') {
return `Context error (${result.error?.code}): ${result.error?.message}`;
}

const data = result.data;
if (!data) {
return 'Context loaded.';
}

if ('changedFiles' in data) {
const lines = [
`PR #${data.number}: ${data.title}`,
`State: ${data.state}`,
`Labels: ${data.labels.join(', ') || '(none)'}`,
`Checks: ${data.checks.state} (${data.checks.passing}/${data.checks.total} passing)`,
`Changed files: ${data.changedFiles.length}`,
data.url
];
if (result.cachedPath) {
lines.push(`Cached: ${result.cachedPath}`);
}
return lines.join('\n');
}

const lines = [
`Issue #${data.number}: ${data.title}`,
`State: ${data.state}`,
`Labels: ${data.labels.join(', ') || '(none)'}`,
`Assignees: ${data.assignees.join(', ') || '(none)'}`,
`Linked references: ${data.linkedReferences.join(', ') || '(none)'}`,
data.url
];
if (result.cachedPath) {
lines.push(`Cached: ${result.cachedPath}`);
}
return lines.join('\n');
}

export function formatContextJsonResult(result: ContextResult<PrContextData | IssueContextData>): string {
return formatKernelJsonResult(result);
}

async function writeContextCache(
rootDir: string,
kind: 'pr' | 'issue',
number: number,
data: PrContextData | IssueContextData,
agentDir: string
): Promise<string> {
const contextDir = join(rootDir, agentDir, 'context');
await mkdir(contextDir, { recursive: true });
const relativePath = join(agentDir, 'context', `${kind}-${number}.json`);
const absolutePath = join(rootDir, relativePath);
await writeFile(absolutePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
return relativePath.replace(/\\/g, '/');
}
Loading