From a8501829e9974006f39ed1efa91bd7450132016b Mon Sep 17 00:00:00 2001 From: kernel-public-sync Date: Wed, 17 Jun 2026 02:56:07 +0000 Subject: [PATCH] chore: sync public export --- .github/workflows/ci.yml | 3 + AGENTS.md | 185 +----------- CHANGELOG.md | 13 + README.md | 5 + src/adapters/canonical-skills.ts | 75 +++++ src/adapters/claude.ts | 32 +- src/adapters/codex.ts | 55 ++-- src/adapters/common.ts | 29 ++ src/adapters/cursor.ts | 39 ++- src/adapters/gemini.ts | 79 +++++ src/adapters/github-copilot.ts | 30 +- src/adapters/index.ts | 62 +++- src/adapters/junie.ts | 46 +++ src/adapters/opencode.ts | 47 +++ src/adapters/types.ts | 2 + src/adapters/windsurf.ts | 59 ++++ src/adapters/zed.ts | 44 +++ src/cli/index.ts | 169 ++++++++++- src/core/adapter-compiler.ts | 19 +- src/core/artifacts.ts | 129 ++++++++ src/core/config.ts | 39 ++- src/core/init.ts | 59 +++- src/core/maps.ts | 280 ++++++++---------- src/core/policy/check.ts | 212 +++++++++++++ src/core/policy/ci.ts | 61 ++++ src/core/policy/defaults.ts | 101 +++++++ src/core/policy/escalation.ts | 261 ++++++++++++++++ src/core/policy/evaluate.ts | 89 ++++++ src/core/policy/loader.ts | 160 ++++++++++ src/core/policy/schema.ts | 49 +++ src/core/policy/types.ts | 56 ++++ src/core/repo-intelligence/codeowners.ts | 67 +++++ src/core/repo-intelligence/commands.ts | 218 ++++++++++++++ src/core/repo-intelligence/glob.ts | 73 +++++ src/core/repo-intelligence/risk.ts | 181 +++++++++++ src/core/repo-intelligence/tests.ts | 105 +++++++ src/core/repo-intelligence/types.ts | 68 +++++ src/core/repo-intelligence/utils.ts | 77 +++++ src/core/repo-intelligence/workspaces.ts | 161 ++++++++++ src/core/validate.ts | 148 ++++++++- tests/artifacts.test.ts | 62 +++- tests/cli.test.ts | 27 ++ tests/config.test.ts | 44 ++- .../.agent/skills/kernel-core/SKILL.md | 24 ++ .../maps-codeowners/.agent/kernel.yaml | 8 + .../maps-codeowners/.github/CODEOWNERS | 2 + tests/fixtures/maps-codeowners/package.json | 4 + .../maps-codeowners/src/auth/login.ts | 3 + tests/fixtures/maps-makefile/Makefile | 10 + tests/fixtures/maps-makefile/package.json | 7 + tests/fixtures/maps-monorepo/package.json | 10 + .../maps-monorepo/packages/app/package.json | 8 + .../maps-monorepo/packages/app/src/index.ts | 1 + .../maps-monorepo/packages/lib/package.json | 7 + .../maps-monorepo/packages/lib/src/index.ts | 1 + tests/fixtures/maps-monorepo/pnpm-lock.yaml | 1 + .../maps-monorepo/pnpm-workspace.yaml | 2 + tests/fixtures/maps-vitest/e2e/login.spec.ts | 1 + tests/fixtures/maps-vitest/package.json | 12 + .../maps-vitest/src/__tests__/app.test.ts | 7 + tests/fixtures/maps-vitest/vitest.config.ts | 7 + .../fixtures/policy-basic/.agent/kernel.yaml | 3 + .../.agent/policies/policy-gate.yaml | 18 ++ .../.agent/maps/commands.json | 15 + .../.agent/policies/policy-gate.yaml | 11 + .../.agent/policies/policy-gate.yaml | 11 + .../.github/workflows/ci.yml | 12 + .../.agent/evidence/migration-task.md | 15 + .../.agent/policies/policy-gate.yaml | 10 + .../.agent/state/current-task.md | 14 + .../.agent/policies/policy-gate.yaml | 12 + .../.agent/policies/policy-gate.yaml | 11 + .../validate-valid/.agent/maps/commands.json | 8 +- .../validate-valid/.agent/maps/repo.json | 15 +- .../validate-valid/.agent/maps/risk.json | 7 +- .../validate-valid/.agent/maps/tests.json | 8 +- .../.agent/policies/policy-gate.yaml | 11 + .../validate-warnings/.agent/maps/repo.json | 15 +- .../.agent/policies/policy-gate.yaml | 11 + tests/gemini-adapter.test.ts | 36 +++ tests/init.test.ts | 30 +- tests/maps.test.ts | 109 ++++++- tests/policy.test.ts | 137 +++++++++ tests/priority-adapters.test.ts | 61 +++- tests/repo-intelligence.test.ts | 34 +++ tests/tier2-adapters.test.ts | 55 ++++ tests/validate.test.ts | 20 ++ 87 files changed, 4083 insertions(+), 441 deletions(-) create mode 100644 src/adapters/canonical-skills.ts create mode 100644 src/adapters/gemini.ts create mode 100644 src/adapters/junie.ts create mode 100644 src/adapters/opencode.ts create mode 100644 src/adapters/windsurf.ts create mode 100644 src/adapters/zed.ts create mode 100644 src/core/policy/check.ts create mode 100644 src/core/policy/ci.ts create mode 100644 src/core/policy/defaults.ts create mode 100644 src/core/policy/escalation.ts create mode 100644 src/core/policy/evaluate.ts create mode 100644 src/core/policy/loader.ts create mode 100644 src/core/policy/schema.ts create mode 100644 src/core/policy/types.ts create mode 100644 src/core/repo-intelligence/codeowners.ts create mode 100644 src/core/repo-intelligence/commands.ts create mode 100644 src/core/repo-intelligence/glob.ts create mode 100644 src/core/repo-intelligence/risk.ts create mode 100644 src/core/repo-intelligence/tests.ts create mode 100644 src/core/repo-intelligence/types.ts create mode 100644 src/core/repo-intelligence/utils.ts create mode 100644 src/core/repo-intelligence/workspaces.ts create mode 100644 tests/fixtures/compile-all-basic/.agent/skills/kernel-core/SKILL.md create mode 100644 tests/fixtures/maps-codeowners/.agent/kernel.yaml create mode 100644 tests/fixtures/maps-codeowners/.github/CODEOWNERS create mode 100644 tests/fixtures/maps-codeowners/package.json create mode 100644 tests/fixtures/maps-codeowners/src/auth/login.ts create mode 100644 tests/fixtures/maps-makefile/Makefile create mode 100644 tests/fixtures/maps-makefile/package.json create mode 100644 tests/fixtures/maps-monorepo/package.json create mode 100644 tests/fixtures/maps-monorepo/packages/app/package.json create mode 100644 tests/fixtures/maps-monorepo/packages/app/src/index.ts create mode 100644 tests/fixtures/maps-monorepo/packages/lib/package.json create mode 100644 tests/fixtures/maps-monorepo/packages/lib/src/index.ts create mode 100644 tests/fixtures/maps-monorepo/pnpm-lock.yaml create mode 100644 tests/fixtures/maps-monorepo/pnpm-workspace.yaml create mode 100644 tests/fixtures/maps-vitest/e2e/login.spec.ts create mode 100644 tests/fixtures/maps-vitest/package.json create mode 100644 tests/fixtures/maps-vitest/src/__tests__/app.test.ts create mode 100644 tests/fixtures/maps-vitest/vitest.config.ts create mode 100644 tests/fixtures/policy-basic/.agent/kernel.yaml create mode 100644 tests/fixtures/policy-basic/.agent/policies/policy-gate.yaml create mode 100644 tests/fixtures/policy-blocked-command/.agent/maps/commands.json create mode 100644 tests/fixtures/policy-blocked-command/.agent/policies/policy-gate.yaml create mode 100644 tests/fixtures/policy-ci-missing/.agent/policies/policy-gate.yaml create mode 100644 tests/fixtures/policy-ci-missing/.github/workflows/ci.yml create mode 100644 tests/fixtures/policy-escalation/.agent/evidence/migration-task.md create mode 100644 tests/fixtures/policy-escalation/.agent/policies/policy-gate.yaml create mode 100644 tests/fixtures/policy-escalation/.agent/state/current-task.md create mode 100644 tests/fixtures/policy-review-path/.agent/policies/policy-gate.yaml create mode 100644 tests/fixtures/validate-adapters/.agent/policies/policy-gate.yaml create mode 100644 tests/fixtures/validate-valid/.agent/policies/policy-gate.yaml create mode 100644 tests/fixtures/validate-warnings/.agent/policies/policy-gate.yaml create mode 100644 tests/gemini-adapter.test.ts create mode 100644 tests/policy.test.ts create mode 100644 tests/repo-intelligence.test.ts create mode 100644 tests/tier2-adapters.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e7d56d..387ef1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,6 @@ jobs: - name: Verify Packed CLI run: pnpm verify:packed + + - name: Kernel policy check + run: pnpm build && node dist/cli/index.js policy check --ci diff --git a/AGENTS.md b/AGENTS.md index 842f5ec..62630a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,188 +1,21 @@ -# AGENTS.md + -This repository builds **Kernel**, a repo-local quality system and portable operating layer for coding agents. +# AGENTS.md -Kernel's purpose is to make agent-written software work more reliable, reviewable, portable, and evidence-backed across Codex, Claude Code, Cursor, Kiro, GitHub Copilot, Gemini CLI, OpenCode, Windsurf, Junie, Zed, and future ADEs. +This repository uses **Kernel**, a repo-local quality system and portable operating layer for coding agents. ## Prime directive No contract, no implementation. No evidence, no completion. No handoff, no continuity. -## Working rules for Codex - -Before non-trivial implementation: +## Working rules -1. Read this `AGENTS.md`. +1. Read this `AGENTS.md` before non-trivial implementation. 2. Read `.agent/kernel.yaml` if present. -3. Create or update `.agent/state/current-task.md`. -4. Identify the relevant Kernel skill or skills. -5. Define goal, non-goals, assumptions, risk zones, verification, and done criteria. -6. Prefer minimal, testable changes. -7. Record verification evidence before claiming completion. -8. Create a handoff packet if work is incomplete, long-running, or likely to move to another ADE. - -## Repository goals - -Build a TypeScript CLI named `kernel` that can: - -- Initialize Kernel in a repository. -- Generate `.agent/` canonical state. -- Create task contracts. -- Create evidence ledgers. -- Create handoff packets. -- Generate repository maps. -- Compile ADE-specific adapters. -- Validate Kernel installation and generated outputs. -- Lint skills and trigger descriptions. -- Run skill regression fixtures. - -## Recommended stack - -Use TypeScript and Node.js unless the repository is intentionally changed. - -Recommended tools: - -- `pnpm` -- `vitest` -- `zod` -- `commander` or `clipanion` -- `tsx` -- `eslint` -- `prettier` - -## Expected commands - -Prefer these commands when available: - -```bash -pnpm install -pnpm typecheck -pnpm lint -pnpm test -pnpm build -``` - -If a command is missing, inspect `package.json` and update this file only if the durable command set is clear. - -## Implementation priorities - -Build in this order: - -1. Project skeleton and CLI help. -2. Config schema and loader. -3. `kernel init`. -4. Task, evidence, and handoff templates. -5. Adapter compiler interface. -6. Codex adapter. -7. Claude Code adapter. -8. Cursor adapter. -9. Kiro adapter. -10. GitHub Copilot adapter. -11. `kernel validate`. -12. Skill linting and regression fixtures. - -## Architecture requirements - -Keep pure rendering logic separate from filesystem writes. - -Recommended structure: - -```txt -src/ - cli/ - core/ - adapters/ - validators/ - templates/ - utils/ -tests/ - fixtures/ - snapshots/ -``` - -## Adapter design - -Kernel has one canonical source under `.agent/`. ADE-specific files are generated outputs. - -Do not manually fork the content logic for every ADE. Implement adapters as renderers over shared Kernel data. +3. Create or update `.agent/state/current-task.md` before implementation. +4. Prefer minimal, testable changes. +5. Record verification evidence before claiming completion. +6. Create a handoff packet when work is incomplete, long-running, or likely to move to another ADE. -Generated outputs should include a header such as: - -```txt - -``` - -When practical, preserve manual sections between markers: - -```txt -``` - -## Quality requirements - -For any non-trivial code change: - -- Add or update tests. -- Run targeted tests. -- Run typecheck when available. -- Record commands and results in evidence. -- Avoid unrelated formatting churn. -- Avoid broad rewrites for surgical fixes. -- Do not change public behavior unless the task contract says so. - -## Risk policy - -Treat these as high-risk: - -- Adapter generation logic. -- File overwrite logic. -- Config migration logic. -- Shell command execution. -- Package publishing. -- CI workflows. -- User-authored instruction files. - -Never silently overwrite user files. Prefer explicit `--force`, dry-run output, backups, or preserved manual sections. - -## Testing policy - -Use snapshot tests for generated adapter outputs. - -Use unit tests for: - -- Config parsing. -- Path resolution. -- Template rendering. -- Adapter output paths. -- Manual section preservation. -- Validation errors. - -Use fixture repositories for: - -- Empty repo. -- TypeScript package. -- Monorepo. -- Existing AGENTS.md. -- Existing ADE-specific files. - -## Documentation policy - -Keep docs Obsidian-friendly: - -- Use Markdown. -- Use wikilinks for internal docs where useful. -- Avoid vendor lock-in in conceptual docs. -- Keep implementation docs precise enough for Codex to act on. - -## Completion standard - -A task is not complete until the response includes: - -- What changed. -- What verification ran. -- What evidence exists. -- What risks remain. -- Any recommended next action. - -If verification could not be run, say so explicitly and mark the work as unverified or partially verified. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9755576..8e75b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ This project follows semantic versioning once public releases begin. - GitHub CI workflow. - npm release-readiness checklist and gated manual release workflow skeleton. - Hardened npm trusted-publishing release workflow constraints and bootstrap documentation. +- Balanced-track product improvements: lint-ready skill doc fixes, 35-skill vault generation, MVP eval fixtures. +- `kernel task show` and `kernel evidence add-command` CLI commands. +- Canonical-skill-driven adapter compiler for priority ADEs. +- Gemini CLI adapter (`kernel compile gemini`) generating `GEMINI.md` and `.gemini/settings.json`. +- Zed, OpenCode, Windsurf, and Junie tier-2 adapters. +- `kernel init --adapters` for selective adapter enablement in generated config. +- Selective `kernel map --commands/--tests/--risk` flags. +- 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 diff --git a/README.md b/README.md index 8381231..7e405ec 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,11 @@ kernel compile claude kernel compile cursor kernel compile kiro kernel compile github-copilot +kernel compile gemini +kernel compile zed +kernel compile opencode +kernel compile windsurf +kernel compile junie ``` ## Development Checks diff --git a/src/adapters/canonical-skills.ts b/src/adapters/canonical-skills.ts new file mode 100644 index 0000000..b9ddc4a --- /dev/null +++ b/src/adapters/canonical-skills.ts @@ -0,0 +1,75 @@ +import { access, readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { KernelConfig } from '../core/config.js'; + +export interface CanonicalSkill { + name: string; + relativePath: string; + content: string; +} + +export async function loadCanonicalSkills(rootDir: string, config: KernelConfig): Promise { + const skillsRoot = join(rootDir, config.canonical.skills_dir); + + if (!(await pathExists(skillsRoot))) { + return []; + } + + const entries = await readdir(skillsRoot, { withFileTypes: true }); + const skills: CanonicalSkill[] = []; + + for (const entry of entries.filter((item) => item.isDirectory()).sort((left, right) => left.name.localeCompare(right.name))) { + const skillPath = join(skillsRoot, entry.name, 'SKILL.md'); + if (!(await pathExists(skillPath))) { + continue; + } + + const content = await readFile(skillPath, 'utf8'); + skills.push({ + name: entry.name, + relativePath: join(config.canonical.skills_dir, entry.name, 'SKILL.md').replace(/\\/g, '/'), + content + }); + } + + return skills; +} + +export function findCanonicalSkill(skills: CanonicalSkill[], name: string): CanonicalSkill | undefined { + return skills.find((skill) => skill.name === name); +} + +export function skillBodyWithoutFrontmatter(content: string): string { + const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/u.exec(content); + return match ? content.slice(match[0].length).trimStart() : content; +} + +export function renderCursorRuleFromSkill(skillName: string, content: string): string { + const body = skillBodyWithoutFrontmatter(content); + const title = body.match(/^#\s+(.+)$/m)?.[1] ?? skillName; + return [`# ${title}`, '', body.replace(/^#\s+.+\n?/m, '').trim()].join('\n'); +} + +export function resolveCursorRuleContent( + skills: CanonicalSkill[], + skillName: string, + fallback: string, + manualSectionLines: string[] +): string { + const skill = findCanonicalSkill(skills, skillName); + const core = skill ? renderCursorRuleFromSkill(skill.name, skill.content) : fallback; + return [...core.split('\n'), '', ...manualSectionLines, ''].join('\n'); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} diff --git a/src/adapters/claude.ts b/src/adapters/claude.ts index 7ca8240..911ec31 100644 --- a/src/adapters/claude.ts +++ b/src/adapters/claude.ts @@ -3,15 +3,31 @@ import { canonicalSourceList, kernelProcedure, manualSection, primeDirective, sk export const claudeAdapter: KernelAdapter = { name: 'claude', - render({ config }) { + render({ config, canonicalSkills }) { const projectName = config.project.name; - return [ + const outputs = [ { path: 'CLAUDE.md', content: renderClaudeMd(projectName), - generated: true, + generated: true as const, preserveManualSections: true - }, + } + ]; + + if (canonicalSkills.length > 0) { + for (const skill of canonicalSkills) { + outputs.push({ + path: `.claude/skills/${skill.name}/SKILL.md`, + content: skill.content, + generated: true as const, + preserveManualSections: true + }); + } + return outputs; + } + + return [ + ...outputs, { path: '.claude/skills/kernel-core/SKILL.md', content: renderClaudeSkill( @@ -20,7 +36,7 @@ export const claudeAdapter: KernelAdapter = { projectName, 'Follow Kernel task contracts, verification evidence, and handoff rules.' ), - generated: true, + generated: true as const, preserveManualSections: true }, { @@ -31,7 +47,7 @@ export const claudeAdapter: KernelAdapter = { projectName, 'Review risk zones, evidence, generated files, and missing validation before approval.' ), - generated: true, + generated: true as const, preserveManualSections: true }, { @@ -42,7 +58,7 @@ export const claudeAdapter: KernelAdapter = { projectName, 'Capture reproduction steps, failing checks, and green verification in Kernel evidence.' ), - generated: true, + generated: true as const, preserveManualSections: true }, { @@ -53,7 +69,7 @@ export const claudeAdapter: KernelAdapter = { projectName, 'Write a concise handoff packet under `.agent/handoffs/` before context is lost.' ), - generated: true, + generated: true as const, preserveManualSections: true } ]; diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts index 02ba0d8..0043183 100644 --- a/src/adapters/codex.ts +++ b/src/adapters/codex.ts @@ -1,22 +1,38 @@ import type { KernelAdapter } from './types.js'; +import { canonicalSourceList, manualSection, primeDirective } from './common.js'; export const codexAdapter: KernelAdapter = { name: 'codex', - render({ config }) { - return [ + render({ config, canonicalSkills }) { + const projectName = config.project.name; + const outputs = [ { path: 'AGENTS.md', - content: renderCodexAgents(config.project.name), - generated: true, - preserveManualSections: true - }, - { - path: '.agents/skills/kernel-core/SKILL.md', - content: renderKernelCoreSkill(config.project.name), - generated: true, + content: renderCodexAgents(projectName), + generated: true as const, preserveManualSections: true } ]; + + for (const skill of canonicalSkills) { + outputs.push({ + path: `.agents/skills/${skill.name}/SKILL.md`, + content: skill.content, + generated: true as const, + preserveManualSections: true + }); + } + + if (canonicalSkills.length === 0) { + outputs.push({ + path: '.agents/skills/kernel-core/SKILL.md', + content: renderFallbackKernelCoreSkill(projectName), + generated: true as const, + preserveManualSections: true + }); + } + + return outputs; } }; @@ -30,16 +46,11 @@ function renderCodexAgents(projectName: string): string { '', '## Prime directive', '', - 'No contract, no implementation. No evidence, no completion. No handoff, no continuity.', + primeDirective(), '', '## Canonical source', '', - '- Kernel config: `.agent/kernel.yaml`', - '- Current task: `.agent/state/current-task.md`', - '- Task contracts: `.agent/contracts/`', - '- Evidence ledgers: `.agent/evidence/`', - '- Handoff packets: `.agent/handoffs/`', - '- Repository maps: `.agent/maps/`', + ...canonicalSourceList(), '', '## Codex workflow', '', @@ -49,14 +60,12 @@ function renderCodexAgents(projectName: string): string { '4. Record verification evidence before claiming completion.', '5. Create a handoff packet when work is incomplete or likely to move to another ADE.', '', - '', - '', - '', + ...manualSection(), '' ].join('\n'); } -function renderKernelCoreSkill(projectName: string): string { +function renderFallbackKernelCoreSkill(projectName: string): string { return [ '---', 'name: kernel-core', @@ -82,9 +91,7 @@ function renderKernelCoreSkill(projectName: string): string { '', 'Durable Kernel artifacts under `.agent/`.', '', - '', - '', - '', + ...manualSection(), '' ].join('\n'); } diff --git a/src/adapters/common.ts b/src/adapters/common.ts index 0b8f370..5ceea24 100644 --- a/src/adapters/common.ts +++ b/src/adapters/common.ts @@ -28,6 +28,35 @@ export function kernelProcedure(): string[] { ]; } +export function renderKernelBootstrapAgents(projectName: string, workflowLabel: string): string { + return [ + '# AGENTS.md', + '', + 'This repository uses **Kernel** for repo-local task contracts, verification evidence, and agent handoffs.', + '', + `Project: ${projectName}`, + '', + '## Prime directive', + '', + primeDirective(), + '', + '## Canonical source', + '', + ...canonicalSourceList(), + '', + `## ${workflowLabel}`, + '', + '1. Read `AGENTS.md` and `.agent/kernel.yaml` before non-trivial implementation.', + '2. Create or update `.agent/state/current-task.md` before implementation.', + '3. Prefer minimal, testable changes.', + '4. Record verification evidence before claiming completion.', + '5. Create a handoff packet when work is incomplete or likely to move to another ADE.', + '', + ...manualSection(), + '' + ].join('\n'); +} + export function skillFrontmatter(name: string, description: string): string[] { return ['---', `name: ${name}`, `description: ${description}`, '---']; } diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 44a52e4..fb684c7 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -1,26 +1,42 @@ +import { resolveCursorRuleContent } from './canonical-skills.js'; import type { KernelAdapter } from './types.js'; import { manualSection, primeDirective } from './common.js'; export const cursorAdapter: KernelAdapter = { name: 'cursor', - render({ config }) { + render({ config, canonicalSkills }) { const projectName = config.project.name; return [ { path: '.cursor/rules/kernel-core.mdc', - content: renderCoreRule(projectName), + content: resolveCursorRuleContent( + canonicalSkills, + 'kernel-core', + renderCoreRule(projectName), + manualSection() + ), generated: true, preserveManualSections: true }, { path: '.cursor/rules/kernel-quality.mdc', - content: renderQualityRule(projectName), + content: resolveCursorRuleContent( + canonicalSkills, + 'verify-lattice', + renderQualityRule(projectName), + manualSection() + ), generated: true, preserveManualSections: true }, { path: '.cursor/rules/kernel-security.mdc', - content: renderSecurityRule(projectName), + content: resolveCursorRuleContent( + canonicalSkills, + 'risk-map', + renderSecurityRule(projectName), + manualSection() + ), generated: true, preserveManualSections: true } @@ -36,10 +52,7 @@ function renderCoreRule(projectName: string): string { '', '- Read `.agent/kernel.yaml`.', '- Read or create `.agent/state/current-task.md`.', - '- Record evidence under `.agent/evidence/` before claiming completion.', - '', - ...manualSection(), - '' + '- Record evidence under `.agent/evidence/` before claiming completion.' ].join('\n'); } @@ -53,10 +66,7 @@ function renderQualityRule(projectName: string): string { '', '- Prefer minimal, testable changes.', '- Run targeted checks that match the task risk.', - '- Record command results and remaining risk in `.agent/evidence/`.', - '', - ...manualSection(), - '' + '- Record command results and remaining risk in `.agent/evidence/`.' ].join('\n'); } @@ -68,9 +78,6 @@ function renderSecurityRule(projectName: string): string { '', '- Inspect `.agent/maps/risk.json` when present.', '- Do not silently perform destructive operations.', - '- Escalate verification for auth, billing, migrations, CI, and publishing changes.', - '', - ...manualSection(), - '' + '- Escalate verification for auth, billing, migrations, CI, and publishing changes.' ].join('\n'); } diff --git a/src/adapters/gemini.ts b/src/adapters/gemini.ts new file mode 100644 index 0000000..d54f53e --- /dev/null +++ b/src/adapters/gemini.ts @@ -0,0 +1,79 @@ +import type { KernelAdapter } from './types.js'; +import { canonicalSourceList, manualSection, primeDirective } from './common.js'; + +export const geminiAdapter: KernelAdapter = { + name: 'gemini', + render({ config, canonicalSkills }) { + const projectName = config.project.name; + const outputs = [ + { + path: 'GEMINI.md', + content: renderGeminiMd(projectName, canonicalSkills), + generated: true as const, + preserveManualSections: true + }, + { + path: '.gemini/settings.json', + content: renderGeminiSettings(projectName, canonicalSkills), + generated: true as const, + preserveManualSections: false + } + ]; + + return outputs; + } +}; + +function renderGeminiMd(projectName: string, canonicalSkills: { name: string }[]): string { + const skillList = + canonicalSkills.length > 0 + ? canonicalSkills.map((skill) => `- \`.agent/skills/${skill.name}/SKILL.md\``).join('\n') + : '- `.agent/skills/kernel-core/SKILL.md`'; + + return [ + '# GEMINI.md', + '', + 'This repository uses **Kernel** for repo-local task contracts, verification evidence, and handoffs.', + '', + `Project: ${projectName}`, + '', + '## Prime directive', + '', + primeDirective(), + '', + '## Canonical source', + '', + ...canonicalSourceList(), + '', + '## Canonical skills', + '', + skillList, + '', + '## Gemini workflow', + '', + '1. Read `GEMINI.md` and `.agent/kernel.yaml` before non-trivial implementation.', + '2. Create or update `.agent/state/current-task.md` before implementation.', + '3. Prefer minimal, testable changes.', + '4. Record verification evidence before claiming completion.', + '5. Create a handoff packet when work is incomplete or likely to move to another ADE.', + '', + ...manualSection(), + '' + ].join('\n'); +} + +function renderGeminiSettings(projectName: string, canonicalSkills: { name: string }[]): string { + return `${JSON.stringify( + { + kernel: { + project: projectName, + instructionsFile: 'GEMINI.md', + canonicalAgentDir: '.agent', + skillsDir: '.agent/skills', + skills: canonicalSkills.map((skill) => skill.name) + } + }, + null, + 2 + )}\n`; +} diff --git a/src/adapters/github-copilot.ts b/src/adapters/github-copilot.ts index de39a81..510324f 100644 --- a/src/adapters/github-copilot.ts +++ b/src/adapters/github-copilot.ts @@ -3,31 +3,47 @@ import { kernelProcedure, manualSection, primeDirective, skillFrontmatter } from export const githubCopilotAdapter: KernelAdapter = { name: 'github-copilot', - render({ config }) { + render({ config, canonicalSkills }) { const projectName = config.project.name; - return [ + const outputs = [ { path: '.github/copilot-instructions.md', content: renderCopilotInstructions(projectName), - generated: true, + generated: true as const, preserveManualSections: true }, { path: '.github/instructions/testing.instructions.md', content: renderTestingInstructions(projectName), - generated: true, + generated: true as const, preserveManualSections: true }, { path: '.github/instructions/review.instructions.md', content: renderReviewInstructions(projectName), - generated: true, + generated: true as const, preserveManualSections: true - }, + } + ]; + + if (canonicalSkills.length > 0) { + for (const skill of canonicalSkills) { + outputs.push({ + path: `.github/skills/${skill.name}/SKILL.md`, + content: skill.content, + generated: true as const, + preserveManualSections: true + }); + } + return outputs; + } + + return [ + ...outputs, { path: '.github/skills/kernel-core/SKILL.md', content: renderCopilotSkill(projectName), - generated: true, + generated: true as const, preserveManualSections: true } ]; diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 44c6fef..b363fe1 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,8 +1,13 @@ import { claudeAdapter } from './claude.js'; import { codexAdapter } from './codex.js'; import { cursorAdapter } from './cursor.js'; +import { geminiAdapter } from './gemini.js'; import { githubCopilotAdapter } from './github-copilot.js'; +import { junieAdapter } from './junie.js'; import { kiroAdapter } from './kiro.js'; +import { opencodeAdapter } from './opencode.js'; +import { windsurfAdapter } from './windsurf.js'; +import { zedAdapter } from './zed.js'; import type { KernelAdapter } from './types.js'; const ADAPTERS = { @@ -10,10 +15,63 @@ const ADAPTERS = { claude: claudeAdapter, cursor: cursorAdapter, kiro: kiroAdapter, - 'github-copilot': githubCopilotAdapter + 'github-copilot': githubCopilotAdapter, + gemini: geminiAdapter, + zed: zedAdapter, + opencode: opencodeAdapter, + windsurf: windsurfAdapter, + junie: junieAdapter } as const satisfies Record; -const ALL_ADAPTER_ORDER = ['codex', 'claude', 'cursor', 'kiro', 'github-copilot'] as const; +const ALL_ADAPTER_ORDER = [ + 'codex', + 'claude', + 'cursor', + 'kiro', + 'github-copilot', + 'gemini', + 'zed', + 'opencode', + 'windsurf', + 'junie' +] as const; + +export const ADAPTER_TARGET_NAMES = Object.keys(ADAPTERS) as AdapterTarget[]; + +export class KernelAdapterTargetError extends Error { + constructor(message: string) { + super(message); + this.name = 'KernelAdapterTargetError'; + } +} + +export function parseAdapterTargetList(value: string): AdapterTarget[] { + const targets = value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + + if (targets.length === 0) { + throw new KernelAdapterTargetError('Adapter list must include at least one adapter target.'); + } + + const unknown = targets.filter((target) => !(target in ADAPTERS)); + if (unknown.length > 0) { + throw new KernelAdapterTargetError( + `Unknown adapter target(s): ${unknown.join(', ')}. Available targets: ${ADAPTER_TARGET_NAMES.join(', ')}.` + ); + } + + return targets as AdapterTarget[]; +} + +export function adapterTargetToConfigKey(target: AdapterTarget): keyof import('../core/config.js').KernelConfig['adapters'] { + if (target === 'github-copilot') { + return 'github_copilot'; + } + + return target; +} export type AdapterTarget = keyof typeof ADAPTERS; export type CompileTarget = AdapterTarget | 'all'; diff --git a/src/adapters/junie.ts b/src/adapters/junie.ts new file mode 100644 index 0000000..3805c0f --- /dev/null +++ b/src/adapters/junie.ts @@ -0,0 +1,46 @@ +import type { KernelAdapter } from './types.js'; +import { canonicalSourceList, manualSection, primeDirective } from './common.js'; + +export const junieAdapter: KernelAdapter = { + name: 'junie', + render({ config }) { + const projectName = config.project.name; + return [ + { + path: '.junie/AGENTS.md', + content: renderJunieAgents(projectName), + generated: true, + preserveManualSections: true + } + ]; + } +}; + +function renderJunieAgents(projectName: string): string { + return [ + '# AGENTS.md', + '', + 'This repository uses **Kernel** for repo-local task contracts, verification evidence, and handoffs.', + '', + `Project: ${projectName}`, + '', + '## Prime directive', + '', + primeDirective(), + '', + '## Canonical source', + '', + ...canonicalSourceList(), + '', + '## Junie workflow', + '', + '1. Read `.junie/AGENTS.md` and `.agent/kernel.yaml` before non-trivial implementation.', + '2. Create or update `.agent/state/current-task.md` before implementation.', + '3. Prefer minimal, testable changes.', + '4. Record verification evidence before claiming completion.', + '5. Create a handoff packet when work is incomplete or likely to move to another ADE.', + '', + ...manualSection(), + '' + ].join('\n'); +} diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts new file mode 100644 index 0000000..46217e6 --- /dev/null +++ b/src/adapters/opencode.ts @@ -0,0 +1,47 @@ +import type { KernelAdapter } from './types.js'; +import { manualSection, skillFrontmatter } from './common.js'; + +export const opencodeAdapter: KernelAdapter = { + name: 'opencode', + render({ config, canonicalSkills }) { + const projectName = config.project.name; + const skills = canonicalSkills.length > 0 ? canonicalSkills : [fallbackKernelCoreSkill(projectName)]; + const outputs = []; + + for (const skill of skills) { + outputs.push({ + path: `.opencode/skills/${skill.name}/SKILL.md`, + content: skill.content, + generated: true as const, + preserveManualSections: true + }); + outputs.push({ + path: `.agents/skills/${skill.name}/SKILL.md`, + content: skill.content, + generated: true as const, + preserveManualSections: true + }); + } + + return outputs; + } +}; + +function fallbackKernelCoreSkill(projectName: string) { + return { + name: 'kernel-core', + content: [ + ...skillFrontmatter( + 'kernel-core', + 'Use before and after non-trivial coding-agent tasks in repositories using Kernel.' + ), + '', + '# kernel-core', + '', + `Follow Kernel's repo-local operating protocol for ${projectName}.`, + '', + ...manualSection(), + '' + ].join('\n') + }; +} diff --git a/src/adapters/types.ts b/src/adapters/types.ts index 2d2ed4f..dedbb84 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -1,7 +1,9 @@ import type { KernelConfig } from '../core/config.js'; +import type { CanonicalSkill } from './canonical-skills.js'; export interface AdapterRenderContext { config: KernelConfig; + canonicalSkills: CanonicalSkill[]; } export interface AdapterOutput { diff --git a/src/adapters/windsurf.ts b/src/adapters/windsurf.ts new file mode 100644 index 0000000..11cf57b --- /dev/null +++ b/src/adapters/windsurf.ts @@ -0,0 +1,59 @@ +import { resolveCursorRuleContent } from './canonical-skills.js'; +import type { KernelAdapter } from './types.js'; +import { manualSection } from './common.js'; +import { renderSkillMarkdownContent } from './zed.js'; + +export const windsurfAdapter: KernelAdapter = { + name: 'windsurf', + render({ config, canonicalSkills }) { + const projectName = config.project.name; + return [ + { + path: '.windsurf/rules/kernel-core.md', + content: resolveCursorRuleContent( + canonicalSkills, + 'kernel-core', + renderWindsurfCoreFallback(projectName), + manualSection() + ), + generated: true, + preserveManualSections: true + }, + { + path: '.windsurf/workflows/kernel-review.md', + content: renderSkillMarkdownContent( + canonicalSkills, + 'verify-lattice', + renderWindsurfReviewFallback(projectName) + ), + generated: true, + preserveManualSections: true + } + ]; + } +}; + +function renderWindsurfCoreFallback(projectName: string): string { + return [ + '# Kernel Core', + '', + `Use Kernel canonical artifacts in ${projectName} before non-trivial implementation.`, + '', + '- Read `.agent/kernel.yaml` and `.agent/state/current-task.md`.', + '- Record evidence in `.agent/evidence/` before claiming completion.' + ].join('\n'); +} + +function renderWindsurfReviewFallback(projectName: string): string { + return [ + '# Kernel Review', + '', + `Select verification level and record evidence for ${projectName} before completion.`, + '', + '- Match verification to task risk.', + '- Record command results in `.agent/evidence/`.', + '', + ...manualSection(), + '' + ].join('\n'); +} diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts new file mode 100644 index 0000000..7e737f7 --- /dev/null +++ b/src/adapters/zed.ts @@ -0,0 +1,44 @@ +import { findCanonicalSkill, resolveCursorRuleContent } from './canonical-skills.js'; +import type { KernelAdapter } from './types.js'; +import { manualSection } from './common.js'; + +export const zedAdapter: KernelAdapter = { + name: 'zed', + render({ config, canonicalSkills }) { + const projectName = config.project.name; + return [ + { + path: '.rules', + content: resolveCursorRuleContent( + canonicalSkills, + 'kernel-core', + renderZedRulesFallback(projectName), + manualSection() + ), + generated: true, + preserveManualSections: true + } + ]; + } +}; + +function renderZedRulesFallback(projectName: string): string { + return [ + '# Kernel', + '', + `Use Kernel's canonical source under \`.agent/\` before non-trivial changes in ${projectName}.`, + '', + '- Read `.agent/kernel.yaml`.', + '- Read or create `.agent/state/current-task.md`.', + '- Record evidence under `.agent/evidence/` before claiming completion.' + ].join('\n'); +} + +export function renderSkillMarkdownContent( + canonicalSkills: Parameters[0]['canonicalSkills'], + skillName: string, + fallback: string +): string { + const skill = findCanonicalSkill(canonicalSkills, skillName); + return skill?.content ?? fallback; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index b1f6d41..2bdc691 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,7 +6,7 @@ import { Command } from 'commander'; import { getAdaptersForTarget } from '../adapters/index.js'; import { compileAdapters } from '../core/adapter-compiler.js'; -import { createEvidenceLedger, createHandoffPacket, createTaskContract } from '../core/artifacts.js'; +import { createEvidenceLedger, createHandoffPacket, createTaskContract, addEvidenceCommand, showTaskContract } from '../core/artifacts.js'; import { formatSkillEvalJsonResult, formatSkillEvalResult, @@ -31,6 +31,11 @@ import { import { generateCanonicalSkills, type GenerateCanonicalSkillsResult } from '../core/skill-generator.js'; import { formatSkillLintJsonResult, formatSkillLintResult, lintKernelSkills } from '../core/skills.js'; import { formatValidationJsonResult, formatValidationResult, validateKernel } from '../core/validate.js'; +import { + checkPolicy, + formatPolicyCheckJsonResult, + formatPolicyCheckResult +} from '../core/policy/check.js'; import { createCliJsonErrorEnvelope, formatCliJsonErrorEnvelope } from './json-errors.js'; export function createKernelProgram(): Command { @@ -46,12 +51,24 @@ export function createKernelProgram(): Command { .description('Initialize Kernel in the current repository.') .option('--force', 'allow overwriting generated files') .option('--dry-run', 'show planned writes without changing files') - .action(async (options: { force?: boolean; dryRun?: boolean }) => { - const result = await initializeKernel(process.cwd(), { - force: Boolean(options.force), - dryRun: Boolean(options.dryRun) - }); - console.log(formatInitResult(result)); + .option('--adapters ', 'comma-separated adapter targets to enable in kernel.yaml') + .action(async (options: { force?: boolean; dryRun?: boolean; adapters?: string }) => { + try { + const result = await initializeKernel(process.cwd(), { + force: Boolean(options.force), + dryRun: Boolean(options.dryRun), + adapters: options.adapters + }); + console.log(formatInitResult(result)); + } catch (error) { + if (error instanceof Error && error.name === 'KernelInitError') { + console.error(error.message); + process.exitCode = 1; + return; + } + + throw error; + } }); program @@ -60,11 +77,26 @@ export function createKernelProgram(): Command { .option('--force', 'allow overwriting map files') .option('--dry-run', 'show planned writes without changing files') .option('--include-docs-vault', 'include kernel_obsidian_vault in the scan') - .action(async (options: { force?: boolean; dryRun?: boolean; includeDocsVault?: boolean }) => { + .option('--commands', 'generate only commands.json') + .option('--tests', 'generate only tests.json') + .option('--risk', 'generate only risk.json') + .action(async (options: { force?: boolean; dryRun?: boolean; includeDocsVault?: boolean; commands?: boolean; tests?: boolean; risk?: boolean }) => { + const maps: ('commands' | 'tests' | 'risk')[] = []; + if (options.commands) { + maps.push('commands'); + } + if (options.tests) { + maps.push('tests'); + } + if (options.risk) { + maps.push('risk'); + } + const result = await generateKernelMaps(process.cwd(), { force: Boolean(options.force), dryRun: Boolean(options.dryRun), - includeDocsVault: Boolean(options.includeDocsVault) + includeDocsVault: Boolean(options.includeDocsVault), + maps: maps.length > 0 ? maps : undefined }); console.log(formatArtifactResult(result.files)); }); @@ -98,6 +130,51 @@ export function createKernelProgram(): Command { } }); + const policy = program.command('policy').description('Evaluate Kernel policy rules.'); + policy + .command('check') + .description('Check commands, paths, task escalation, and CI policy compliance.') + .option('--command ', 'classify a single command string') + .option('--path ', 'classify a single repository path') + .option('--task ', 'check verification escalation for a task (use current)') + .option('--ci', 'check CI workflow compliance') + .option('--strict', 'treat warnings as errors') + .option('--json', 'print machine-readable JSON') + .action(async (options: { + command?: string; + path?: string; + task?: string; + ci?: boolean; + strict?: boolean; + json?: boolean; + }) => { + try { + const result = await checkPolicy({ + command: options.command, + path: options.path, + task: options.task, + ci: Boolean(options.ci), + strict: Boolean(options.strict) + }); + + if (options.json) { + process.stdout.write(formatPolicyCheckJsonResult(result)); + } else { + process.stdout.write(`${formatPolicyCheckResult(result)}\n`); + } + + if (result.status === 'fail') { + process.exitCode = 1; + } + } catch (error) { + if (writeJsonErrorEnvelope('policy check', Boolean(options.json), error)) { + return; + } + + throw error; + } + }); + program .command('compile') .description('Compile Kernel canonical source into ADE-specific adapter files.') @@ -149,6 +226,35 @@ export function createKernelProgram(): Command { } ); + task + .command('show') + .description('Show the current task contract or a task contract by id.') + .option('--id ', 'task id') + .option('--json', 'print machine-readable JSON') + .action(async (options: { id?: string; json?: boolean }) => { + try { + const result = await showTaskContract(process.cwd(), { id: options.id }); + if (options.json) { + process.stdout.write(formatKernelJsonResult(result)); + return; + } + + console.log(formatTaskContractView(result)); + } catch (error) { + if (writeJsonErrorEnvelope('task show', Boolean(options.json), error)) { + return; + } + + if (error instanceof Error) { + console.error(error.message); + process.exitCode = 1; + return; + } + + throw error; + } + }); + const evidence = program.command('evidence').description('Create and update Kernel evidence ledgers.'); evidence .command('new') @@ -167,6 +273,42 @@ export function createKernelProgram(): Command { console.log(formatArtifactResult(result.files)); }); + evidence + .command('add-command') + .description('Append a verification command to an evidence ledger.') + .argument('', 'verification command') + .option('--task ', 'task id or current', 'current') + .option('--exit-code ', 'command exit code') + .option('--result ', 'command result summary') + .option('--notes ', 'additional notes') + .option('--dry-run', 'show planned writes without changing files') + .action( + async ( + command: string, + options: { task: string; exitCode?: string; result?: string; notes?: string; dryRun?: boolean } + ) => { + try { + const result = await addEvidenceCommand(process.cwd(), { + task: options.task, + command, + exitCode: options.exitCode, + result: options.result, + notes: options.notes, + dryRun: Boolean(options.dryRun) + }); + console.log(formatArtifactResult(result.files)); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + process.exitCode = 1; + return; + } + + throw error; + } + } + ); + const handoff = program.command('handoff').description('Create Kernel handoff packets.'); handoff .command('new') @@ -399,6 +541,15 @@ function formatArtifactResult(files: { action: string; relativePath: string }[]) return files.map((entry) => `${entry.action}: ${entry.relativePath}`).join('\n'); } +function formatTaskContractView(view: { + id: string; + type: string; + goal: string; + relativePath: string; +}): string { + return [`Task: ${view.id}`, `Type: ${view.type}`, `Goal: ${view.goal}`, `Path: ${view.relativePath}`].join('\n'); +} + export function formatSkillGenerateResult(result: GenerateCanonicalSkillsResult): string { const lines = result.files.map((entry) => `${entry.action}: ${entry.relativePath}`); diff --git a/src/core/adapter-compiler.ts b/src/core/adapter-compiler.ts index 7ad7e89..b716356 100644 --- a/src/core/adapter-compiler.ts +++ b/src/core/adapter-compiler.ts @@ -2,6 +2,7 @@ import { access, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { KernelAdapter } from '../adapters/types.js'; +import { loadCanonicalSkills } from '../adapters/canonical-skills.js'; import { loadKernelConfig } from './config.js'; import { KernelFileExistsError, type KernelWriteAction, writeKernelFile } from './fs.js'; import { preserveManualSections, withGeneratedHeader } from './manual-sections.js'; @@ -47,20 +48,22 @@ export async function compileAdapters( options: CompileAdapterOptions = {} ): Promise { const config = await loadKernelConfig(rootDir); + const canonicalSkills = await loadCanonicalSkills(rootDir, config); const adapterOutputs = adapters.map((adapter) => ({ adapterName: adapter.name, - outputs: adapter.render({ config }) + outputs: adapter.render({ config, canonicalSkills }) })); const outputs = adapterOutputs.flatMap(({ adapterName, outputs }) => outputs.map((output) => ({ adapterName, output })) ); + const dedupedOutputs = dedupeAdapterOutputs(outputs); if (!options.force && !options.dryRun) { - await assertNoExistingOutputs(rootDir, outputs.map(({ output }) => output.path)); + await assertNoExistingOutputs(rootDir, dedupedOutputs.map(({ output }) => output.path)); } const files: CompileAdapterFileResult[] = []; - for (const { adapterName, output } of outputs) { + for (const { adapterName, output } of dedupedOutputs) { const targetPath = join(rootDir, output.path); const existingContent = output.preserveManualSections ? await readExistingGeneratedFile(targetPath) : undefined; const content = output.generated ? renderGeneratedAdapterFile(output.content, existingContent) : output.content; @@ -90,6 +93,16 @@ export function renderGeneratedAdapterFile(content: string, existingContent?: st return withGeneratedHeader(preserved); } +function dedupeAdapterOutputs(outputs: T[]): T[] { + const deduped = new Map(); + for (const entry of outputs) { + if (!deduped.has(entry.output.path)) { + deduped.set(entry.output.path, entry); + } + } + return [...deduped.values()]; +} + async function assertNoExistingOutputs(rootDir: string, relativePaths: string[]): Promise { for (const relativePath of relativePaths) { const path = join(rootDir, relativePath); diff --git a/src/core/artifacts.ts b/src/core/artifacts.ts index af7f84d..acf6b66 100644 --- a/src/core/artifacts.ts +++ b/src/core/artifacts.ts @@ -30,6 +30,28 @@ export interface CreateHandoffPacketOptions { force?: boolean; } +export interface AddEvidenceCommandOptions { + task: string; + command: string; + exitCode?: string; + result?: string; + notes?: string; + dryRun?: boolean; + force?: boolean; +} + +export interface ShowTaskContractOptions { + id?: string; +} + +export interface TaskContractView { + id: string; + type: string; + goal: string; + relativePath: string; + content: string; +} + export interface ArtifactFileResult { relativePath: string; path: string; @@ -116,6 +138,63 @@ export async function createHandoffPacket( }; } +export async function addEvidenceCommand( + rootDir: string = process.cwd(), + options: AddEvidenceCommandOptions +): Promise { + const taskId = await resolveTaskId(rootDir, options.task); + const paths = await getArtifactPaths(rootDir); + const relativePath = joinRelative(paths.evidenceDir, `${taskId}.md`); + const evidencePath = join(rootDir, relativePath); + + let existingContent: string; + try { + existingContent = await readFile(evidencePath, 'utf8'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + throw new KernelArtifactError(`Evidence ledger not found at ${relativePath}. Run kernel evidence new first.`); + } + throw error; + } + + const updatedContent = appendEvidenceCommandRow(existingContent, { + command: options.command, + exitCode: options.exitCode ?? '', + result: options.result ?? '', + notes: options.notes ?? '' + }); + const file = await writeArtifact(rootDir, relativePath, updatedContent, { + dryRun: options.dryRun, + force: true + }); + + return { + taskId, + files: [file] + }; +} + +export async function showTaskContract( + rootDir: string = process.cwd(), + options: ShowTaskContractOptions = {} +): Promise { + const paths = await getArtifactPaths(rootDir); + const relativePath = + options.id === undefined + ? joinRelative(paths.stateDir, 'current-task.md') + : joinRelative(paths.contractsDir, `${normalizeTaskId(options.id)}.md`); + const content = await readFile(join(rootDir, relativePath), 'utf8'); + const parsed = parseTaskContractContent(content); + + return { + id: parsed.id, + type: parsed.type, + goal: parsed.goal, + relativePath, + content + }; +} + export interface RenderTaskContractInput { id: string; type: TaskType | string; @@ -328,3 +407,53 @@ function joinRelative(...parts: string[]): string { .filter(Boolean) .join('/'); } + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +function parseTaskContractContent(content: string): { id: string; type: string; goal: string } { + const idMatch = /^# Task Contract:\s*(.+?)\s*$/m.exec(content); + const typeMatch = /^Type:\s*(.+?)\s*$/m.exec(content); + const goalMatch = /^Goal:\s*(.+?)\s*$/m.exec(content); + + if (!idMatch?.[1] || !typeMatch?.[1] || !goalMatch?.[1]) { + throw new KernelArtifactError('Task contract is missing required fields (id, type, or goal).'); + } + + return { + id: idMatch[1].trim(), + type: typeMatch[1].trim(), + goal: goalMatch[1].trim() + }; +} + +function appendEvidenceCommandRow( + content: string, + row: { command: string; exitCode: string; result: string; notes: string } +): string { + const marker = '## Commands run'; + const markerIndex = content.indexOf(marker); + if (markerIndex === -1) { + throw new KernelArtifactError('Evidence ledger is missing the Commands run section.'); + } + + const tableHeader = '| Command | Exit code | Result | Notes |'; + const tableHeaderIndex = content.indexOf(tableHeader, markerIndex); + if (tableHeaderIndex === -1) { + throw new KernelArtifactError('Evidence ledger is missing the commands table header.'); + } + + const separatorIndex = content.indexOf('|---|---:|---|---|', tableHeaderIndex); + if (separatorIndex === -1) { + throw new KernelArtifactError('Evidence ledger is missing the commands table separator.'); + } + + const rowLine = `| ${escapeTableCell(row.command)} | ${escapeTableCell(row.exitCode)} | ${escapeTableCell(row.result)} | ${escapeTableCell(row.notes)} |`; + const insertIndex = content.indexOf('\n', separatorIndex) + 1; + return `${content.slice(0, insertIndex)}${rowLine}\n${content.slice(insertIndex)}`; +} + +function escapeTableCell(value: string): string { + return value.replace(/\|/g, '\\|'); +} diff --git a/src/core/config.ts b/src/core/config.ts index cb3800d..8bec9cd 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -30,13 +30,26 @@ const defaultConfigValues = { claude: true, cursor: true, kiro: true, - github_copilot: true + github_copilot: true, + gemini: false, + zed: false, + opencode: false, + windsurf: false, + junie: false }, skills: { generated_set: 'mvp' }, eval: { default_runner: 'static' + }, + commands: {}, + risk: { + high_risk_paths: [], + destructive_commands: [] + }, + maps: { + include_codeowners: true } } as const; @@ -71,7 +84,12 @@ export const kernelConfigSchema = z claude: z.boolean().default(defaultConfigValues.adapters.claude), cursor: z.boolean().default(defaultConfigValues.adapters.cursor), kiro: z.boolean().default(defaultConfigValues.adapters.kiro), - github_copilot: z.boolean().default(defaultConfigValues.adapters.github_copilot) + github_copilot: z.boolean().default(defaultConfigValues.adapters.github_copilot), + gemini: z.boolean().default(defaultConfigValues.adapters.gemini), + zed: z.boolean().default(defaultConfigValues.adapters.zed), + opencode: z.boolean().default(defaultConfigValues.adapters.opencode), + windsurf: z.boolean().default(defaultConfigValues.adapters.windsurf), + junie: z.boolean().default(defaultConfigValues.adapters.junie) }) .default(defaultConfigValues.adapters), skills: z @@ -83,7 +101,22 @@ export const kernelConfigSchema = z .object({ default_runner: z.literal(defaultConfigValues.eval.default_runner).default(defaultConfigValues.eval.default_runner) }) - .default(defaultConfigValues.eval) + .default(defaultConfigValues.eval), + commands: z.record(z.string(), z.string()).default(defaultConfigValues.commands), + risk: z + .object({ + high_risk_paths: z.array(z.string()).default([...defaultConfigValues.risk.high_risk_paths]), + destructive_commands: z.array(z.string()).default([...defaultConfigValues.risk.destructive_commands]) + }) + .default({ + high_risk_paths: [...defaultConfigValues.risk.high_risk_paths], + destructive_commands: [...defaultConfigValues.risk.destructive_commands] + }), + maps: z + .object({ + include_codeowners: z.boolean().default(defaultConfigValues.maps.include_codeowners) + }) + .default(defaultConfigValues.maps) }) .strict(); diff --git a/src/core/init.ts b/src/core/init.ts index 285915c..2631696 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -3,14 +3,24 @@ import { join } from 'node:path'; import { stringify as stringifyYaml } from 'yaml'; -import { DEFAULT_KERNEL_CONFIG } from './config.js'; +import { adapterTargetToConfigKey, type AdapterTarget, KernelAdapterTargetError, parseAdapterTargetList } from '../adapters/index.js'; +import { DEFAULT_KERNEL_CONFIG, type KernelConfig, kernelConfigSchema } from './config.js'; import { KernelFileExistsError, type KernelWriteAction, writeKernelFile } from './fs.js'; +import { renderDefaultPolicyGate } from './policy/defaults.js'; export type InitDirectoryAction = 'created' | 'exists' | 'would-create' | 'would-exist'; export interface InitializeKernelOptions { dryRun?: boolean; force?: boolean; + adapters?: string; +} + +export class KernelInitError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'KernelInitError'; + } } export interface InitDirectoryResult { @@ -54,7 +64,19 @@ export async function initializeKernel( rootDir: string = process.cwd(), options: InitializeKernelOptions = {} ): Promise { - const filePlans = getInitFilePlans(); + let enabledAdapters: AdapterTarget[] | undefined; + if (options.adapters !== undefined) { + try { + enabledAdapters = parseAdapterTargetList(options.adapters); + } catch (error) { + if (error instanceof KernelAdapterTargetError) { + throw new KernelInitError(error.message, { cause: error }); + } + throw error; + } + } + + const filePlans = getInitFilePlans(enabledAdapters); if (!options.force && !options.dryRun) { await assertNoExistingFiles(rootDir, filePlans); @@ -82,8 +104,27 @@ export async function initializeKernel( return { directories, files }; } -export function renderDefaultKernelConfig(): string { - return stringifyYaml(DEFAULT_KERNEL_CONFIG); +export function renderDefaultKernelConfig(enabledAdapters?: AdapterTarget[]): string { + return stringifyYaml(buildInitKernelConfig(enabledAdapters)); +} + +export function buildInitKernelConfig(enabledAdapters?: AdapterTarget[]): KernelConfig { + if (enabledAdapters === undefined) { + return DEFAULT_KERNEL_CONFIG; + } + + const adapters = Object.fromEntries( + Object.keys(DEFAULT_KERNEL_CONFIG.adapters).map((key) => [key, false]) + ) as KernelConfig['adapters']; + + for (const target of enabledAdapters) { + adapters[adapterTargetToConfigKey(target)] = true; + } + + return kernelConfigSchema.parse({ + ...DEFAULT_KERNEL_CONFIG, + adapters + }); } export function renderDefaultAgentsMd(): string { @@ -112,11 +153,17 @@ export function renderDefaultAgentsMd(): string { ].join('\n'); } -function getInitFilePlans(): InitFilePlan[] { +function getInitFilePlans(enabledAdapters?: AdapterTarget[]): InitFilePlan[] { return [ { relativePath: '.agent/kernel.yaml', - content: renderDefaultKernelConfig(), + content: renderDefaultKernelConfig(enabledAdapters), + generatedHeader: false, + preserveManualSections: false + }, + { + relativePath: '.agent/policies/policy-gate.yaml', + content: renderDefaultPolicyGate(buildInitKernelConfig(enabledAdapters)), generatedHeader: false, preserveManualSections: false }, diff --git a/src/core/maps.ts b/src/core/maps.ts index c6d4c67..3142f3b 100644 --- a/src/core/maps.ts +++ b/src/core/maps.ts @@ -1,26 +1,59 @@ -import { access, readdir, readFile, stat } from 'node:fs/promises'; -import { basename, join } from 'node:path'; +import { access, readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; import { loadKernelConfig } from './config.js'; import { KernelFileExistsError, type KernelWriteAction, writeKernelFile } from './fs.js'; +import { loadCodeownersRules } from './repo-intelligence/codeowners.js'; +import { detectCommands } from './repo-intelligence/commands.js'; +import { inferRisk } from './repo-intelligence/risk.js'; +import { detectTests } from './repo-intelligence/tests.js'; +import type { + CommandEntry, + ConfigRiskPathEntry, + EntrypointEntry, + MonorepoInfo, + OwnershipEntry, + PackageScriptsEntry, + RepoFileEntry, + RiskCommandEntry, + RiskPathEntry, + TaskRunnerTask, + TestFramework, + WorkspacePackage +} from './repo-intelligence/types.js'; +import { compareStrings } from './repo-intelligence/utils.js'; +import { detectWorkspaces } from './repo-intelligence/workspaces.js'; + +export type MapKind = 'repo' | 'commands' | 'tests' | 'risk'; + +export type { + CommandEntry, + ConfigRiskPathEntry, + EntrypointEntry, + MonorepoInfo, + OwnershipEntry, + PackageScriptsEntry, + RepoFileEntry, + RiskCommandEntry, + RiskPathEntry, + TaskRunnerTask, + TestFramework, + WorkspacePackage +}; export interface GenerateKernelMapsOptions { dryRun?: boolean; force?: boolean; includeDocsVault?: boolean; + maps?: MapKind[]; } export interface ScanRepositoryMapsOptions { includeDocsVault?: boolean; } -export interface RepoFileEntry { - path: string; - sizeBytes: number; -} - export interface RepoMap { - version: 1; + version: 2; files: RepoFileEntry[]; directories: string[]; ignoredDirectories: string[]; @@ -28,40 +61,38 @@ export interface RepoMap { fileCount: number; directoryCount: number; }; -} - -export interface CommandEntry { - name: string; - command: string; - script: string; + monorepo: MonorepoInfo; + packages: WorkspacePackage[]; + entrypoints: EntrypointEntry[]; } export interface CommandsMap { - version: 1; + version: 2; packageManager: string | null; scripts: CommandEntry[]; + sources: Array<'package.json' | 'makefile' | 'justfile' | 'kernel.yaml' | 'turbo' | 'nx'>; + kernelCommands: CommandEntry[]; + packageScripts: PackageScriptsEntry[]; + taskRunnerTasks: TaskRunnerTask[]; } export interface TestsMap { - version: 1; + version: 2; testFiles: string[]; testCommands: CommandEntry[]; -} - -export interface RiskPathEntry { - path: string; - reason: string; -} - -export interface RiskCommandEntry extends CommandEntry { - reason: string; + frameworks: TestFramework[]; + configFiles: string[]; + e2ePaths: string[]; + patterns: string[]; } export interface RiskMap { - version: 1; + version: 2; highRiskPaths: RiskPathEntry[]; destructiveCommands: RiskCommandEntry[]; ignoredDirectories: string[]; + ownership: OwnershipEntry[]; + configRiskPaths: ConfigRiskPathEntry[]; } export interface KernelMaps { @@ -82,10 +113,6 @@ export interface GenerateKernelMapsResult { files: MapFileResult[]; } -interface PackageJson { - scripts?: Record; -} - const BASE_IGNORED_DIRECTORIES = ['.git', 'dist', 'node_modules']; const DOCS_VAULT_DIRECTORY = 'kernel_obsidian_vault'; @@ -93,41 +120,61 @@ export async function scanRepositoryMaps( rootDir: string = process.cwd(), options: ScanRepositoryMapsOptions = {} ): Promise { + const config = await loadKernelConfig(rootDir); const ignoredDirectories = getIgnoredDirectories(options.includeDocsVault); const { files, directories } = await scanFiles(rootDir, ignoredDirectories); - const packageManager = await detectPackageManager(rootDir); - const scripts = await readPackageScripts(rootDir, packageManager); - const testFiles = files.map((file) => file.path).filter(isTestFile).sort(compareStrings); - const testCommands = scripts.filter((script) => script.name.includes('test')).sort(compareCommandEntries); - const highRiskPaths = files.map((file) => file.path).flatMap(classifyHighRiskPath).sort(compareRiskPaths); - const destructiveCommands = scripts.flatMap(classifyDestructiveCommand).sort(compareRiskCommands); + const filePaths = files.map((file) => file.path); + const workspaceInfo = await detectWorkspaces(rootDir); + const commandInfo = await detectCommands(rootDir, config, workspaceInfo.packages); + const testInfo = detectTests(filePaths, commandInfo.scripts); + const codeownersRules = await loadCodeownersRules(rootDir); + const riskInfo = inferRisk({ + filePaths, + scripts: commandInfo.scripts, + ignoredDirectories, + config, + codeownersRules + }); return { repo: { - version: 1, + version: 2, files, directories, ignoredDirectories, summary: { fileCount: files.length, directoryCount: directories.length - } + }, + monorepo: workspaceInfo.monorepo, + packages: workspaceInfo.packages, + entrypoints: workspaceInfo.entrypoints }, commands: { - version: 1, - packageManager, - scripts + version: 2, + packageManager: commandInfo.packageManager, + scripts: commandInfo.scripts, + sources: commandInfo.sources, + kernelCommands: commandInfo.kernelCommands, + packageScripts: commandInfo.packageScripts, + taskRunnerTasks: commandInfo.taskRunnerTasks }, tests: { - version: 1, - testFiles, - testCommands + version: 2, + testFiles: testInfo.testFiles, + testCommands: testInfo.testCommands, + frameworks: testInfo.frameworks, + configFiles: testInfo.configFiles, + e2ePaths: testInfo.e2ePaths, + patterns: testInfo.patterns }, risk: { - version: 1, - highRiskPaths, - destructiveCommands, - ignoredDirectories + version: 2, + highRiskPaths: riskInfo.highRiskPaths, + destructiveCommands: riskInfo.destructiveCommands, + ignoredDirectories, + ownership: riskInfo.ownership, + configRiskPaths: riskInfo.configRiskPaths } }; } @@ -138,12 +185,14 @@ export async function generateKernelMaps( ): Promise { const maps = await scanRepositoryMaps(rootDir, { includeDocsVault: options.includeDocsVault }); const config = await loadKernelConfig(rootDir); - const plans = [ - { relativePath: joinRelative(config.canonical.maps_dir, 'repo.json'), content: stringifyMap(maps.repo) }, - { relativePath: joinRelative(config.canonical.maps_dir, 'commands.json'), content: stringifyMap(maps.commands) }, - { relativePath: joinRelative(config.canonical.maps_dir, 'tests.json'), content: stringifyMap(maps.tests) }, - { relativePath: joinRelative(config.canonical.maps_dir, 'risk.json'), content: stringifyMap(maps.risk) } + const selectedMaps = resolveSelectedMaps(options); + const allPlans = [ + { kind: 'repo' as const, relativePath: joinRelative(config.canonical.maps_dir, 'repo.json'), content: stringifyMap(maps.repo) }, + { kind: 'commands' as const, relativePath: joinRelative(config.canonical.maps_dir, 'commands.json'), content: stringifyMap(maps.commands) }, + { kind: 'tests' as const, relativePath: joinRelative(config.canonical.maps_dir, 'tests.json'), content: stringifyMap(maps.tests) }, + { kind: 'risk' as const, relativePath: joinRelative(config.canonical.maps_dir, 'risk.json'), content: stringifyMap(maps.risk) } ]; + const plans = allPlans.filter((plan) => selectedMaps.has(plan.kind)); if (!options.force && !options.dryRun) { await assertNoExistingMapFiles(rootDir, plans.map((plan) => plan.relativePath)); @@ -221,92 +270,6 @@ function shouldIgnoreDirectory(relativePath: string, name: string, ignoredDirect return relativePath === '.agent/maps'; } -async function detectPackageManager(rootDir: string): Promise { - if (await pathExists(join(rootDir, 'pnpm-lock.yaml'))) { - return 'pnpm'; - } - if (await pathExists(join(rootDir, 'package-lock.json'))) { - return 'npm'; - } - if (await pathExists(join(rootDir, 'yarn.lock'))) { - return 'yarn'; - } - if (await pathExists(join(rootDir, 'bun.lockb'))) { - return 'bun'; - } - if (await pathExists(join(rootDir, 'package.json'))) { - return 'npm'; - } - - return null; -} - -async function readPackageScripts(rootDir: string, packageManager: string | null): Promise { - const packagePath = join(rootDir, 'package.json'); - if (!(await pathExists(packagePath)) || packageManager === null) { - return []; - } - - const packageJson = JSON.parse(await readFile(packagePath, 'utf8')) as PackageJson; - const scripts = packageJson.scripts ?? {}; - return Object.entries(scripts) - .filter((entry): entry is [string, string] => typeof entry[1] === 'string') - .map(([name, script]) => ({ - name, - command: `${packageManager} ${name}`, - script - })) - .sort(compareCommandEntries); -} - -function isTestFile(path: string): boolean { - const fileName = basename(path); - return ( - path.startsWith('tests/') || - path.startsWith('test/') || - /\.test\.[cm]?[jt]sx?$/.test(fileName) || - /\.spec\.[cm]?[jt]sx?$/.test(fileName) - ); -} - -function classifyHighRiskPath(path: string): RiskPathEntry[] { - if (path.startsWith('.github/workflows/')) { - return [{ path, reason: 'CI workflow' }]; - } - if (path.startsWith('db/migrations/')) { - return [{ path, reason: 'database migration' }]; - } - if (path.startsWith('infra/')) { - return [{ path, reason: 'infrastructure' }]; - } - if (path.startsWith('auth/') || path.includes('/auth/')) { - return [{ path, reason: 'authentication' }]; - } - if (path.startsWith('billing/') || path.includes('/billing/')) { - return [{ path, reason: 'billing' }]; - } - - return []; -} - -function classifyDestructiveCommand(command: CommandEntry): RiskCommandEntry[] { - const script = command.script; - if (script.includes('npm publish') || script.includes('pnpm publish')) { - return [{ ...command, reason: 'package publishing' }]; - } - if (script.includes('git push --force')) { - return [{ ...command, reason: 'force push' }]; - } - if (script.includes('git reset --hard')) { - return [{ ...command, reason: 'destructive git reset' }]; - } - if (script.includes('rm -rf')) { - return [{ ...command, reason: 'recursive deletion' }]; - } - - return []; -} - async function assertNoExistingMapFiles(rootDir: string, relativePaths: string[]): Promise { for (const relativePath of relativePaths) { const path = join(rootDir, relativePath); @@ -320,22 +283,6 @@ function stringifyMap(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } -function compareStrings(left: string, right: string): number { - return left.localeCompare(right, 'en'); -} - -function compareCommandEntries(left: CommandEntry, right: CommandEntry): number { - return compareStrings(left.name, right.name); -} - -function compareRiskPaths(left: RiskPathEntry, right: RiskPathEntry): number { - return compareStrings(left.path, right.path); -} - -function compareRiskCommands(left: RiskCommandEntry, right: RiskCommandEntry): number { - return compareStrings(left.name, right.name); -} - async function pathExists(path: string): Promise { try { await access(path); @@ -358,3 +305,26 @@ function joinRelative(...parts: string[]): string { .filter(Boolean) .join('/'); } + +export function resolveSelectedMaps(options: GenerateKernelMapsOptions): Set { + const subset = [options.maps?.includes('commands'), options.maps?.includes('tests'), options.maps?.includes('risk')].some( + Boolean + ); + + if (!subset) { + return new Set(['repo', 'commands', 'tests', 'risk']); + } + + const selected = new Set(); + if (options.maps?.includes('commands')) { + selected.add('commands'); + } + if (options.maps?.includes('tests')) { + selected.add('tests'); + } + if (options.maps?.includes('risk')) { + selected.add('risk'); + } + + return selected; +} diff --git a/src/core/policy/check.ts b/src/core/policy/check.ts new file mode 100644 index 0000000..baa76a7 --- /dev/null +++ b/src/core/policy/check.ts @@ -0,0 +1,212 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { loadKernelConfig } from '../config.js'; +import { checkCiPolicy } from './ci.js'; +import { classifyCommand, classifyPath, scanRepoCommands, scanRepoPaths } from './evaluate.js'; +import { inferVerificationLevel, isVerificationSufficient, loadTaskContext, resolveEscalation } from './escalation.js'; +import { hasPolicyFiles, loadPolicies } from './loader.js'; +import type { PolicyGate } from './schema.js'; +import type { PolicyCheckResult, PolicyViolation } from './types.js'; + +export interface PolicyCheckOptions { + command?: string; + path?: string; + task?: string; + ci?: boolean; + strict?: boolean; + rootDir?: string; +} + +interface CommandsMap { + scripts: Array<{ name: string; command: string; script: string }>; +} + +interface RiskMap { + highRiskPaths: Array<{ path: string; reason: string }>; +} + +export async function checkPolicy(options: PolicyCheckOptions = {}): Promise { + const rootDir = options.rootDir ?? process.cwd(); + const strict = Boolean(options.strict); + const config = await loadKernelConfig(rootDir); + const { policyGate, sourceFiles } = await loadPolicies(rootDir, config); + const violations: PolicyViolation[] = []; + + if (!(await hasPolicyFiles(rootDir))) { + violations.push({ + code: 'missing_policy_file', + severity: 'warning', + path: '.agent/policies/policy-gate.yaml', + message: 'No policy files found; using built-in defaults merged with kernel.yaml risk settings.', + policyClass: 'safe' + }); + } + + if (options.command) { + violations.push(...commandToViolations(classifyCommand(options.command, policyGate), options.command)); + } + + if (options.path) { + violations.push(...pathToViolations(classifyPath(options.path, policyGate))); + } + + if (!options.command && !options.path) { + violations.push(...(await scanRepoViolations(rootDir, policyGate))); + } + + if (options.task) { + violations.push(...(await checkTaskEscalation(rootDir, policyGate, options.task))); + } + + if (options.ci) { + violations.push(...(await ciToViolations(rootDir, policyGate))); + } + + return buildPolicyCheckResult(violations, strict, sourceFiles); +} + +async function scanRepoViolations(rootDir: string, policy: PolicyGate): Promise { + const violations: PolicyViolation[] = []; + const commandsMap = await readOptionalJson(join(rootDir, '.agent', 'maps', 'commands.json')); + const riskMap = await readOptionalJson(join(rootDir, '.agent', 'maps', 'risk.json')); + + if (commandsMap) { + for (const classified of scanRepoCommands(commandsMap.scripts, policy)) { + violations.push(...commandToViolations(classified, classified.command)); + } + } + + const paths = riskMap?.highRiskPaths.map((entry) => entry.path) ?? []; + for (const classified of scanRepoPaths(paths, policy)) { + violations.push(...pathToViolations(classified)); + } + + return violations; +} + +async function checkTaskEscalation(rootDir: string, policy: PolicyGate, task: string): Promise { + const taskContext = await loadTaskContext(rootDir, task); + const requirement = resolveEscalation(policy, taskContext, taskContext.riskZones); + const actual = inferVerificationLevel(taskContext); + + if (isVerificationSufficient(actual, requirement.minVerification)) { + return []; + } + + return [ + { + code: 'insufficient_verification_level', + severity: 'warning', + path: `.agent/evidence/${taskContext.id}.md`, + message: `Task ${taskContext.id} evidence is at ${actual} but policy requires ${requirement.minVerification}.`, + policyClass: 'review' + } + ]; +} + +async function ciToViolations(rootDir: string, policy: PolicyGate): Promise { + const result = await checkCiPolicy(rootDir, policy); + return result.missingChecks.map((check) => ({ + code: 'missing_ci_check', + severity: 'warning', + path: '.github/workflows', + message: `CI workflow is missing required check command: ${check}`, + policyClass: 'review' + })); +} + +function commandToViolations(classified: ReturnType, subject: string): PolicyViolation[] { + if (classified.policyClass === 'safe') { + return []; + } + + return [ + { + code: classified.policyClass === 'block' ? 'policy_command_blocked' : 'policy_command_review', + severity: classified.policyClass === 'block' ? 'error' : 'warning', + path: subject, + message: `${classified.policyClass} command: ${classified.reason}`, + policyClass: classified.policyClass + } + ]; +} + +function pathToViolations(classified: ReturnType): PolicyViolation[] { + if (classified.policyClass === 'safe') { + return []; + } + + return [ + { + code: classified.policyClass === 'block' ? 'policy_path_block' : 'policy_path_review', + severity: classified.policyClass === 'block' ? 'error' : 'warning', + path: classified.path, + message: `${classified.policyClass} path: ${classified.reason}`, + policyClass: classified.policyClass + } + ]; +} + +function buildPolicyCheckResult(violations: PolicyViolation[], strict: boolean, sourceFiles: string[]): PolicyCheckResult { + const sorted = [...violations].sort((left, right) => { + return ( + left.code.localeCompare(right.code, 'en') || + left.path.localeCompare(right.path, 'en') || + left.message.localeCompare(right.message, 'en') + ); + }); + + const blockCount = sorted.filter((violation) => violation.policyClass === 'block').length; + const reviewCount = sorted.filter((violation) => violation.policyClass === 'review').length; + const warningCount = sorted.filter((violation) => violation.severity === 'warning').length; + const errorCount = sorted.filter((violation) => violation.severity === 'error').length; + const status = errorCount > 0 || (strict && warningCount > 0) ? 'fail' : warningCount > 0 ? 'warn' : 'pass'; + + if (sourceFiles.length > 0 && sorted.length === 0) { + return { + status: 'pass', + strict, + blockCount: 0, + reviewCount: 0, + warningCount: 0, + violations: sorted + }; + } + + return { + status, + strict, + blockCount, + reviewCount, + warningCount, + violations: sorted + }; +} + +async function readOptionalJson(path: string): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) as T; + } catch { + return null; + } +} + +export function formatPolicyCheckResult(result: PolicyCheckResult): string { + const lines = [ + `Policy status: ${result.status}`, + `Blocks: ${result.blockCount}`, + `Reviews: ${result.reviewCount}`, + `Warnings: ${result.warningCount}` + ]; + + for (const violation of result.violations) { + lines.push(`${violation.severity} ${violation.code} ${violation.path} - ${violation.message}`); + } + + return lines.join('\n'); +} + +export function formatPolicyCheckJsonResult(result: PolicyCheckResult): string { + return `${JSON.stringify(result, null, 2)}\n`; +} diff --git a/src/core/policy/ci.ts b/src/core/policy/ci.ts new file mode 100644 index 0000000..cf96941 --- /dev/null +++ b/src/core/policy/ci.ts @@ -0,0 +1,61 @@ +import { access, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { PolicyGate } from './schema.js'; +import { extractWorkflowRunCommands } from './escalation.js'; +import type { CiCheckResult } from './types.js'; + +export async function checkCiPolicy(rootDir: string, policy: PolicyGate): Promise { + const workflowsDir = join(rootDir, '.github', 'workflows'); + if (!(await pathExists(workflowsDir))) { + return { + provider: null, + requiredChecks: policy.ci.required_checks, + foundChecks: [], + missingChecks: [] + }; + } + + const entries = await readdir(workflowsDir); + const workflowFiles = entries.filter((entry) => entry.endsWith('.yml') || entry.endsWith('.yaml')).sort(); + const foundChecks = new Set(); + + for (const fileName of workflowFiles) { + const commands = await extractWorkflowRunCommands(join(workflowsDir, fileName)); + for (const command of commands) { + foundChecks.add(command); + } + } + + const found = [...foundChecks].sort(); + const missingChecks = policy.ci.required_checks.filter( + (required) => !found.some((command) => commandIncludes(command, required)) + ); + + return { + provider: workflowFiles.length > 0 ? policy.ci.provider : null, + requiredChecks: [...policy.ci.required_checks], + foundChecks: found, + missingChecks + }; +} + +function commandIncludes(haystack: string, needle: string): boolean { + return haystack.toLowerCase().includes(needle.toLowerCase()); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return false; + } + throw error; + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} diff --git a/src/core/policy/defaults.ts b/src/core/policy/defaults.ts new file mode 100644 index 0000000..7fb1be2 --- /dev/null +++ b/src/core/policy/defaults.ts @@ -0,0 +1,101 @@ +import { stringify as stringifyYaml } from 'yaml'; + +import type { KernelConfig } from '../config.js'; +import type { PolicyGate } from './schema.js'; + +export const DEFAULT_POLICY_GATE: PolicyGate = { + version: 1, + commands: [ + { class: 'block', match: 'npm publish', reason: 'package publishing' }, + { class: 'block', match: 'pnpm publish', reason: 'package publishing' }, + { class: 'block', match: 'git push --force', reason: 'force push' }, + { class: 'block', match: 'git reset --hard', reason: 'destructive git reset' }, + { class: 'block', match: 'rm -rf', reason: 'recursive deletion' }, + { class: 'review', match: 'pnpm install', reason: 'dependency install' }, + { class: 'review', match: 'npm install', reason: 'dependency install' } + ], + paths: [ + { + pattern: '.github/workflows/**', + class: 'review', + reason: 'CI workflow', + min_verification: 'L3', + required_skills: ['verify-lattice'] + }, + { + pattern: 'src/core/**', + class: 'review', + reason: 'core runtime', + min_verification: 'L3', + required_skills: ['verify-lattice'] + }, + { + pattern: 'src/adapters/**', + class: 'review', + reason: 'adapter compiler output', + min_verification: 'L3', + required_skills: ['verify-lattice'] + } + ], + escalation: { + by_task_type: { + 'docs-only': 'L0', + 'surgical-fix': 'L1', + bugfix: 'L1', + feature: 'L2', + refactor: 'L3', + migration: 'L5', + exploration: 'L0', + incident: 'L3' + }, + by_path: [ + { + pattern: 'auth/**', + min_verification: 'L5', + required_skills: ['security-tripwire'], + required_commands: [] + } + ] + }, + ci: { + provider: 'github-actions', + required_checks: ['pnpm test', 'pnpm typecheck', 'pnpm lint', 'pnpm build'] + } +}; + +export function renderDefaultPolicyGate(config?: KernelConfig): string { + const policy = mergeRiskIntoPolicy(DEFAULT_POLICY_GATE, config); + return stringifyYaml(policy); +} + +export function mergeRiskIntoPolicy(policy: PolicyGate, config?: KernelConfig): PolicyGate { + if (!config) { + return policy; + } + + const existingMatches = new Set(policy.commands.map((rule) => rule.match)); + const riskCommands = config.risk.destructive_commands + .filter((match) => !existingMatches.has(match)) + .map((match) => ({ + class: 'block' as const, + match, + reason: `configured destructive command: ${match}` + })); + + const existingPatterns = new Set(policy.paths.map((rule) => rule.pattern)); + const riskPaths = config.risk.high_risk_paths + .filter((pattern) => !existingPatterns.has(pattern)) + .map((pattern) => ({ + pattern, + class: 'review' as const, + reason: 'configured high-risk path', + min_verification: 'L3' as const, + required_skills: ['verify-lattice'] + })); + + return { + ...policy, + commands: [...policy.commands, ...riskCommands], + paths: [...policy.paths, ...riskPaths] + }; +} diff --git a/src/core/policy/escalation.ts b/src/core/policy/escalation.ts new file mode 100644 index 0000000..22bf78b --- /dev/null +++ b/src/core/policy/escalation.ts @@ -0,0 +1,261 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { parse as parseYaml } from 'yaml'; + +import { matchPathPattern } from '../repo-intelligence/glob.js'; +import type { PolicyGate } from './schema.js'; +import type { EscalationRequirement, VerificationLevel } from './types.js'; + +const VERIFICATION_RANK: Record = { + L0: 0, + L1: 1, + L2: 2, + L3: 3, + L4: 4, + L5: 5 +}; + +export interface TaskContext { + id: string; + type: string; + riskZones: string[]; + evidenceCommands: string[]; + completionStatus: string; +} + +export function resolveEscalation(policy: PolicyGate, task: TaskContext, paths: string[] = []): EscalationRequirement { + const reasons: string[] = []; + let minVerification: VerificationLevel = 'L0'; + const requiredSkills = new Set(); + const requiredCommands = new Set(); + + const taskTypeLevel = policy.escalation.by_task_type[task.type]; + if (taskTypeLevel) { + minVerification = maxVerification(minVerification, taskTypeLevel); + reasons.push(`task type ${task.type} requires ${taskTypeLevel}`); + } + + for (const zone of task.riskZones) { + for (const rule of policy.escalation.by_path) { + if (matchPathPattern(rule.pattern, zone)) { + minVerification = maxVerification(minVerification, rule.min_verification); + for (const skill of rule.required_skills) { + requiredSkills.add(skill); + } + for (const command of rule.required_commands) { + requiredCommands.add(command); + } + reasons.push(`risk zone ${zone} matches ${rule.pattern}`); + } + } + } + + for (const path of paths) { + for (const rule of policy.escalation.by_path) { + if (matchPathPattern(rule.pattern, path)) { + minVerification = maxVerification(minVerification, rule.min_verification); + for (const skill of rule.required_skills) { + requiredSkills.add(skill); + } + for (const command of rule.required_commands) { + requiredCommands.add(command); + } + reasons.push(`path ${path} matches ${rule.pattern}`); + } + } + + for (const rule of policy.paths) { + if (rule.min_verification && matchPathPattern(rule.pattern, path)) { + minVerification = maxVerification(minVerification, rule.min_verification); + for (const skill of rule.required_skills) { + requiredSkills.add(skill); + } + reasons.push(`path ${path} matches policy path ${rule.pattern}`); + } + } + } + + return { + minVerification, + requiredSkills: [...requiredSkills].sort(), + requiredCommands: [...requiredCommands].sort(), + reasons: reasons.sort() + }; +} + +export function inferVerificationLevel(task: TaskContext): VerificationLevel { + const commands = task.evidenceCommands.map((command) => command.toLowerCase()); + const status = task.completionStatus.toLowerCase(); + + if (status.includes('specialized') || status.includes('l5')) { + return 'L5'; + } + if (status.includes('project checks') || status.includes('verified by project')) { + return 'L3'; + } + if (status.includes('targeted tests') || status.includes('partially verified')) { + return 'L1'; + } + if (status.includes('verified') && !status.includes('unverified')) { + return 'L3'; + } + + const hasTypecheck = commands.some((command) => command.includes('typecheck')); + const hasLint = commands.some((command) => command.includes('lint')); + const hasBuild = commands.some((command) => command.includes('build')); + const hasTest = commands.some((command) => command.includes('test')); + + if (hasTypecheck && hasLint && hasBuild && hasTest) { + return 'L3'; + } + if (hasTest) { + return 'L1'; + } + if (status.includes('unverified')) { + return 'L0'; + } + + return 'L0'; +} + +export function isVerificationSufficient(actual: VerificationLevel, required: VerificationLevel): boolean { + return VERIFICATION_RANK[actual] >= VERIFICATION_RANK[required]; +} + +export async function loadTaskContext(rootDir: string, task: string): Promise { + const taskId = await resolveTaskId(rootDir, task); + const contractPath = join(rootDir, '.agent', 'state', 'current-task.md'); + const contract = await readFile(contractPath, 'utf8'); + const typeMatch = /^Type:\s*(.+?)\s*$/m.exec(contract); + const riskSection = extractListSection(contract, 'Risk zones:'); + const evidencePath = join(rootDir, '.agent', 'evidence', `${taskId}.md`); + + let evidenceCommands: string[] = []; + let completionStatus = 'unverified'; + try { + const evidence = await readFile(evidencePath, 'utf8'); + evidenceCommands = parseEvidenceCommands(evidence); + completionStatus = parseCompletionStatus(evidence); + } catch { + // evidence may be absent + } + + return { + id: taskId, + type: typeMatch?.[1]?.trim() ?? 'feature', + riskZones: riskSection, + evidenceCommands, + completionStatus + }; +} + +async function resolveTaskId(rootDir: string, task: string): Promise { + if (task !== 'current') { + return normalizeTaskId(task); + } + + const contractPath = join(rootDir, '.agent', 'state', 'current-task.md'); + const contract = await readFile(contractPath, 'utf8'); + const match = /^# Task Contract:\s*(.+?)\s*$/m.exec(contract); + if (!match?.[1]) { + throw new Error(`Could not resolve current task id from ${contractPath}.`); + } + return normalizeTaskId(match[1]); +} + +function normalizeTaskId(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function extractListSection(content: string, heading: string): string[] { + const headingIndex = content.indexOf(heading); + if (headingIndex === -1) { + return []; + } + + const afterHeading = content.slice(headingIndex + heading.length); + const nextHeadingIndex = afterHeading.search(/\n[A-Za-z][^\n]*:\n/); + const section = nextHeadingIndex === -1 ? afterHeading : afterHeading.slice(0, nextHeadingIndex); + return section + .split('\n') + .map((line) => line.replace(/^-\s*/, '').trim()) + .filter((line) => line && line !== 'None.'); +} + +function parseEvidenceCommands(content: string): string[] { + const marker = '## Commands run'; + const markerIndex = content.indexOf(marker); + if (markerIndex === -1) { + return []; + } + + const lines = content.slice(markerIndex).split('\n'); + const commands: string[] = []; + for (const line of lines) { + if (!line.startsWith('|') || line.includes('Command | Exit code')) { + continue; + } + if (line.includes('---')) { + continue; + } + const cells = line.split('|').map((cell) => cell.trim()).filter(Boolean); + if (cells[0]) { + commands.push(cells[0]); + } + } + return commands; +} + +function parseCompletionStatus(content: string): string { + const marker = '## Completion status'; + const markerIndex = content.indexOf(marker); + if (markerIndex === -1) { + return 'unverified'; + } + const after = content.slice(markerIndex + marker.length).trim(); + const line = after.split('\n').find((entry) => entry.trim().length > 0); + return line?.trim() ?? 'unverified'; +} + +function maxVerification(left: VerificationLevel, right: VerificationLevel): VerificationLevel { + return VERIFICATION_RANK[left] >= VERIFICATION_RANK[right] ? left : right; +} + +export async function extractWorkflowRunCommands(workflowPath: string): Promise { + const content = await readFile(workflowPath, 'utf8'); + const parsed = parseYaml(content) as unknown; + const commands = new Set(); + collectRunCommands(parsed, commands); + return [...commands].sort(); +} + +function collectRunCommands(value: unknown, commands: Set): void { + if (Array.isArray(value)) { + for (const entry of value) { + collectRunCommands(entry, commands); + } + return; + } + + if (!value || typeof value !== 'object') { + return; + } + + for (const [key, child] of Object.entries(value)) { + if (key === 'run' && typeof child === 'string') { + for (const line of child.split('\n')) { + const trimmed = line.trim(); + if (trimmed) { + commands.add(trimmed); + } + } + } else { + collectRunCommands(child, commands); + } + } +} diff --git a/src/core/policy/evaluate.ts b/src/core/policy/evaluate.ts new file mode 100644 index 0000000..fffb149 --- /dev/null +++ b/src/core/policy/evaluate.ts @@ -0,0 +1,89 @@ +import { matchPathPattern } from '../repo-intelligence/glob.js'; +import type { PolicyGate } from './schema.js'; +import type { ClassifiedCommand, ClassifiedPath, PolicyClass } from './types.js'; + +export function classifyCommand(command: string, policy: PolicyGate): ClassifiedCommand { + const haystack = command.toLowerCase(); + let best: ClassifiedCommand = { + command, + policyClass: 'safe', + reason: 'no matching policy rule', + source: 'default' + }; + + for (const rule of policy.commands) { + if (!haystack.includes(rule.match.toLowerCase())) { + continue; + } + const policyClass = rule.class; + if (comparePolicyClass(policyClass, best.policyClass) > 0) { + best = { + command, + policyClass, + reason: rule.reason ?? `matches policy rule: ${rule.match}`, + source: 'policy-gate.yaml' + }; + } + } + + return best; +} + +export function classifyPath(path: string, policy: PolicyGate): ClassifiedPath { + let best: ClassifiedPath = { + path, + policyClass: 'safe', + reason: 'no matching policy rule' + }; + + for (const rule of policy.paths) { + if (!matchPathPattern(rule.pattern, path)) { + continue; + } + if (comparePolicyClass(rule.class, best.policyClass) >= 0) { + best = { + path, + policyClass: rule.class, + reason: rule.reason ?? `matches policy pattern: ${rule.pattern}`, + minVerification: rule.min_verification, + requiredSkills: rule.required_skills + }; + } + } + + return best; +} + +export function scanRepoCommands( + scripts: Array<{ name: string; command: string; script: string }>, + policy: PolicyGate +): ClassifiedCommand[] { + const results: ClassifiedCommand[] = []; + for (const script of scripts) { + const classified = classifyCommand(`${script.command} ${script.script}`, policy); + if (classified.policyClass !== 'safe') { + results.push({ + ...classified, + command: script.command, + reason: `${classified.reason} (script: ${script.name})` + }); + } + } + return results.sort((left, right) => left.command.localeCompare(right.command, 'en')); +} + +export function scanRepoPaths(paths: string[], policy: PolicyGate): ClassifiedPath[] { + const results: ClassifiedPath[] = []; + for (const path of paths) { + const classified = classifyPath(path, policy); + if (classified.policyClass !== 'safe') { + results.push(classified); + } + } + return results.sort((left, right) => left.path.localeCompare(right.path, 'en')); +} + +function comparePolicyClass(left: PolicyClass, right: PolicyClass): number { + const rank: Record = { safe: 0, review: 1, block: 2 }; + return rank[left] - rank[right]; +} diff --git a/src/core/policy/loader.ts b/src/core/policy/loader.ts new file mode 100644 index 0000000..26088a5 --- /dev/null +++ b/src/core/policy/loader.ts @@ -0,0 +1,160 @@ +import { access, readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { parse as parseYaml } from 'yaml'; + +import type { KernelConfig } from '../config.js'; +import { DEFAULT_POLICY_GATE, mergeRiskIntoPolicy } from './defaults.js'; +import { type PolicyGate, policyGateSchema } from './schema.js'; + +export const POLICY_GATE_FILE = join('.agent', 'policies', 'policy-gate.yaml'); + +export class KernelPolicyError extends Error { + constructor( + message: string, + readonly policyPath: string, + options?: ErrorOptions + ) { + super(message, options); + this.name = 'KernelPolicyError'; + } +} + +export interface LoadedPolicies { + policyGate: PolicyGate; + sourceFiles: string[]; +} + +export async function loadPolicies(rootDir: string, config?: KernelConfig): Promise { + const policiesDir = join(rootDir, '.agent', 'policies'); + const sourceFiles: string[] = []; + + if (!(await pathExists(policiesDir))) { + return { + policyGate: mergeRiskIntoPolicy(DEFAULT_POLICY_GATE, config), + sourceFiles + }; + } + + const entries = await readdir(policiesDir); + const yamlFiles = entries.filter((entry) => entry.endsWith('.yaml') || entry.endsWith('.yml')).sort(); + + if (yamlFiles.length === 0) { + return { + policyGate: mergeRiskIntoPolicy(DEFAULT_POLICY_GATE, config), + sourceFiles + }; + } + + let mergedPolicy: PolicyGate | undefined; + for (const fileName of yamlFiles) { + const relativePath = join('.agent', 'policies', fileName); + const absolutePath = join(rootDir, relativePath); + sourceFiles.push(relativePath.replace(/\\/g, '/')); + const parsed = await parsePolicyFile(absolutePath, relativePath.replace(/\\/g, '/')); + mergedPolicy = mergedPolicy ? mergePolicyFiles(mergedPolicy, parsed) : parsed; + } + + return { + policyGate: mergeRiskIntoPolicy(mergedPolicy ?? DEFAULT_POLICY_GATE, config), + sourceFiles + }; +} + +export async function hasPolicyFiles(rootDir: string): Promise { + const policiesDir = join(rootDir, '.agent', 'policies'); + if (!(await pathExists(policiesDir))) { + return false; + } + + const entries = await readdir(policiesDir); + return entries.some((entry) => entry.endsWith('.yaml') || entry.endsWith('.yml')); +} + +async function parsePolicyFile(absolutePath: string, relativePath: string): Promise { + let parsed: unknown; + try { + parsed = parseYaml(await readFile(absolutePath, 'utf8')) ?? {}; + } catch (error) { + throw new KernelPolicyError(`Failed to parse ${relativePath} as YAML.`, relativePath, { cause: error }); + } + + const result = policyGateSchema.safeParse(parsed); + if (!result.success) { + throw new KernelPolicyError(`Invalid policy in ${relativePath}.`, relativePath, { cause: result.error }); + } + + return result.data; +} + +function mergePolicyFiles(left: PolicyGate, right: PolicyGate): PolicyGate { + return { + version: 1, + commands: dedupeCommandRules([...left.commands, ...right.commands]), + paths: dedupePathRules([...left.paths, ...right.paths]), + escalation: { + by_task_type: { ...left.escalation.by_task_type, ...right.escalation.by_task_type }, + by_path: dedupeEscalationRules([...left.escalation.by_path, ...right.escalation.by_path]) + }, + ci: { + provider: right.ci.provider ?? left.ci.provider, + required_checks: [...new Set([...left.ci.required_checks, ...right.ci.required_checks])].sort() + } + }; +} + +function dedupeCommandRules(rules: PolicyGate['commands']): PolicyGate['commands'] { + const seen = new Set(); + const deduped: PolicyGate['commands'] = []; + for (const rule of rules) { + const key = `${rule.class}:${rule.match}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(rule); + } + return deduped; +} + +function dedupePathRules(rules: PolicyGate['paths']): PolicyGate['paths'] { + const seen = new Set(); + const deduped: PolicyGate['paths'] = []; + for (const rule of rules) { + if (seen.has(rule.pattern)) { + continue; + } + seen.add(rule.pattern); + deduped.push(rule); + } + return deduped; +} + +function dedupeEscalationRules(rules: PolicyGate['escalation']['by_path']): PolicyGate['escalation']['by_path'] { + const seen = new Set(); + const deduped: PolicyGate['escalation']['by_path'] = []; + for (const rule of rules) { + if (seen.has(rule.pattern)) { + continue; + } + seen.add(rule.pattern); + deduped.push(rule); + } + return deduped; +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return false; + } + throw error; + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} diff --git a/src/core/policy/schema.ts b/src/core/policy/schema.ts new file mode 100644 index 0000000..6973b6d --- /dev/null +++ b/src/core/policy/schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +const policyClassSchema = z.enum(['safe', 'review', 'block']); +const verificationLevelSchema = z.enum(['L0', 'L1', 'L2', 'L3', 'L4', 'L5']); + +export const policyCommandRuleSchema = z.object({ + class: policyClassSchema, + match: z.string().min(1), + reason: z.string().optional() +}); + +export const policyPathRuleSchema = z.object({ + pattern: z.string().min(1), + class: policyClassSchema.default('review'), + reason: z.string().optional(), + min_verification: verificationLevelSchema.optional(), + required_skills: z.array(z.string()).default([]) +}); + +export const policyEscalationPathRuleSchema = z.object({ + pattern: z.string().min(1), + min_verification: verificationLevelSchema, + required_skills: z.array(z.string()).default([]), + required_commands: z.array(z.string()).default([]) +}); + +export const policyGateSchema = z + .object({ + version: z.literal(1), + commands: z.array(policyCommandRuleSchema).default([]), + paths: z.array(policyPathRuleSchema).default([]), + escalation: z + .object({ + by_task_type: z.record(z.string(), verificationLevelSchema).default({}), + by_path: z.array(policyEscalationPathRuleSchema).default([]) + }) + .default({ by_task_type: {}, by_path: [] }), + ci: z + .object({ + provider: z.enum(['github-actions']).default('github-actions'), + required_checks: z.array(z.string()).default([]) + }) + .default({ provider: 'github-actions', required_checks: [] }) + }) + .strict(); + +export type PolicyGate = z.infer; +export type PolicyCommandRule = z.infer; +export type PolicyPathRule = z.infer; diff --git a/src/core/policy/types.ts b/src/core/policy/types.ts new file mode 100644 index 0000000..2deb0da --- /dev/null +++ b/src/core/policy/types.ts @@ -0,0 +1,56 @@ +export type PolicyClass = 'safe' | 'review' | 'block'; + +export type VerificationLevel = 'L0' | 'L1' | 'L2' | 'L3' | 'L4' | 'L5'; + +export interface PolicyViolation { + code: + | 'policy_command_blocked' + | 'policy_command_review' + | 'policy_path_review' + | 'policy_path_block' + | 'insufficient_verification_level' + | 'missing_ci_check' + | 'missing_policy_file'; + severity: 'error' | 'warning'; + path: string; + message: string; + policyClass: PolicyClass; +} + +export interface PolicyCheckResult { + status: 'pass' | 'warn' | 'fail'; + strict: boolean; + blockCount: number; + reviewCount: number; + warningCount: number; + violations: PolicyViolation[]; +} + +export interface ClassifiedCommand { + command: string; + policyClass: PolicyClass; + reason: string; + source: string; +} + +export interface ClassifiedPath { + path: string; + policyClass: PolicyClass; + reason: string; + minVerification?: VerificationLevel; + requiredSkills?: string[]; +} + +export interface EscalationRequirement { + minVerification: VerificationLevel; + requiredSkills: string[]; + requiredCommands: string[]; + reasons: string[]; +} + +export interface CiCheckResult { + provider: string | null; + requiredChecks: string[]; + foundChecks: string[]; + missingChecks: string[]; +} diff --git a/src/core/repo-intelligence/codeowners.ts b/src/core/repo-intelligence/codeowners.ts new file mode 100644 index 0000000..e63e6b5 --- /dev/null +++ b/src/core/repo-intelligence/codeowners.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { CodeownersRule } from './types.js'; +import { compareStrings, pathExists } from './utils.js'; + +const CODEOWNERS_LOCATIONS = ['CODEOWNERS', join('.github', 'CODEOWNERS'), join('docs', 'CODEOWNERS')] as const; + +export async function loadCodeownersRules(rootDir: string): Promise { + const rules: CodeownersRule[] = []; + + for (const relativePath of CODEOWNERS_LOCATIONS) { + const absolutePath = join(rootDir, relativePath); + if (!(await pathExists(absolutePath))) { + continue; + } + const content = await readFile(absolutePath, 'utf8'); + rules.push(...parseCodeowners(content, relativePath.replace(/\\/g, '/'))); + } + + return rules; +} + +export function parseCodeowners(content: string, source: string): CodeownersRule[] { + const rules: CodeownersRule[] = []; + + for (const rawLine of content.split(/\r?\n/)) { + const line = stripComment(rawLine).trim(); + if (!line) { + continue; + } + + const tokens = line.split(/\s+/); + const pattern = tokens[0]; + const owners = tokens.slice(1).filter((token) => token.startsWith('@')); + if (!pattern || owners.length === 0) { + continue; + } + + rules.push({ + pattern: pattern.replace(/\\/g, '/').replace(/^\//, ''), + owners: owners.sort(compareStrings), + source + }); + } + + return rules; +} + +function stripComment(line: string): string { + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === '#') { + return line.slice(0, index); + } + } + return line; +} diff --git a/src/core/repo-intelligence/commands.ts b/src/core/repo-intelligence/commands.ts new file mode 100644 index 0000000..8cb8c1e --- /dev/null +++ b/src/core/repo-intelligence/commands.ts @@ -0,0 +1,218 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { KernelConfig } from '../config.js'; +import type { + CommandEntry, + CommandSource, + PackageScriptsEntry, + TaskRunnerTask, + WorkspacePackage +} from './types.js'; +import { compareCommandEntries, compareStrings, pathExists, readPackageJson, readPackageScripts } from './utils.js'; + +const MAKEFILE_TARGETS = ['build', 'clean', 'install', 'lint', 'test'] as const; +const JUSTFILE_TARGETS = ['build', 'clean', 'install', 'lint', 'test'] as const; + +export interface DetectCommandsResult { + packageManager: string | null; + scripts: CommandEntry[]; + sources: CommandSource[]; + kernelCommands: CommandEntry[]; + packageScripts: PackageScriptsEntry[]; + taskRunnerTasks: TaskRunnerTask[]; +} + +export async function detectPackageManager(rootDir: string): Promise { + if (await pathExists(join(rootDir, 'pnpm-lock.yaml'))) { + return 'pnpm'; + } + if (await pathExists(join(rootDir, 'package-lock.json'))) { + return 'npm'; + } + if (await pathExists(join(rootDir, 'yarn.lock'))) { + return 'yarn'; + } + if (await pathExists(join(rootDir, 'bun.lockb'))) { + return 'bun'; + } + if (await pathExists(join(rootDir, 'package.json'))) { + return 'npm'; + } + + return null; +} + +export async function detectCommands( + rootDir: string, + config: KernelConfig, + workspacePackages: WorkspacePackage[] = [] +): Promise { + const packageManager = await detectPackageManager(rootDir); + const sources = new Set(); + const scripts: CommandEntry[] = []; + const packageScripts: PackageScriptsEntry[] = []; + + const rootScripts = await readPackageScripts(join(rootDir, 'package.json'), packageManager, 'root'); + if (rootScripts.length > 0) { + sources.add('package.json'); + scripts.push(...rootScripts); + } + + for (const pkg of workspacePackages) { + const pkgScripts = await readPackageScripts(join(rootDir, pkg.path, 'package.json'), packageManager, pkg.name); + if (pkgScripts.length > 0) { + sources.add('package.json'); + packageScripts.push({ + package: pkg.name, + path: pkg.path, + scripts: pkgScripts + }); + } + } + + const makefileScripts = await readMakefileTargets(rootDir); + if (makefileScripts.length > 0) { + sources.add('makefile'); + scripts.push(...makefileScripts); + } + + const justfileScripts = await readJustfileTargets(rootDir); + if (justfileScripts.length > 0) { + sources.add('justfile'); + scripts.push(...justfileScripts); + } + + const kernelCommands = readKernelCommands(config); + if (kernelCommands.length > 0) { + sources.add('kernel.yaml'); + } + + const taskRunnerTasks = await detectTaskRunnerTasks(rootDir); + if (taskRunnerTasks.length > 0) { + if (taskRunnerTasks.some((task) => task.source === 'turbo.json')) { + sources.add('turbo'); + } + if (taskRunnerTasks.some((task) => task.source === 'nx.json')) { + sources.add('nx'); + } + } + + return { + packageManager, + scripts: dedupeCommands(scripts).sort(compareCommandEntries), + sources: [...sources].sort(compareStrings) as CommandSource[], + kernelCommands: kernelCommands.sort(compareCommandEntries), + packageScripts: packageScripts.sort((left, right) => compareStrings(left.path, right.path)), + taskRunnerTasks: taskRunnerTasks.sort((left, right) => compareStrings(left.name, right.name)) + }; +} + +function readKernelCommands(config: KernelConfig): CommandEntry[] { + return Object.entries(config.commands) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string') + .map(([name, command]) => ({ + name, + command, + script: command + })); +} + +async function readMakefileTargets(rootDir: string): Promise { + const makefilePath = join(rootDir, 'Makefile'); + if (!(await pathExists(makefilePath))) { + return []; + } + + const content = await readFile(makefilePath, 'utf8'); + const targets = parseRecipeTargets(content, MAKEFILE_TARGETS); + return targets.map((name) => ({ + name: `make:${name}`, + command: `make ${name}`, + script: name + })); +} + +async function readJustfileTargets(rootDir: string): Promise { + for (const fileName of ['justfile', 'Justfile']) { + const justfilePath = join(rootDir, fileName); + if (!(await pathExists(justfilePath))) { + continue; + } + const content = await readFile(justfilePath, 'utf8'); + const targets = parseRecipeTargets(content, JUSTFILE_TARGETS); + return targets.map((name) => ({ + name: `just:${name}`, + command: `just ${name}`, + script: name + })); + } + return []; +} + +function parseRecipeTargets(content: string, allowedTargets: readonly string[]): string[] { + const found = new Set(); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('.')) { + continue; + } + const match = /^([A-Za-z0-9_.-]+)\s*:/.exec(trimmed); + if (!match?.[1]) { + continue; + } + if ((allowedTargets as readonly string[]).includes(match[1])) { + found.add(match[1]); + } + } + return [...found].sort(compareStrings); +} + +async function detectTaskRunnerTasks(rootDir: string): Promise { + const tasks: TaskRunnerTask[] = []; + const turboPath = join(rootDir, 'turbo.json'); + if (await pathExists(turboPath)) { + const turboJson = await readPackageJson(turboPath); + const pipeline = (turboJson as { pipeline?: Record; tasks?: Record }).pipeline + ?? (turboJson as { tasks?: Record }).tasks; + if (pipeline && typeof pipeline === 'object') { + for (const name of Object.keys(pipeline).sort(compareStrings)) { + tasks.push({ + name, + command: `turbo run ${name}`, + source: 'turbo.json' + }); + } + } + } + + const nxPath = join(rootDir, 'nx.json'); + if (await pathExists(nxPath)) { + const nxJson = await readPackageJson(nxPath); + const targetDefaults = (nxJson as { targetDefaults?: Record }).targetDefaults; + if (targetDefaults && typeof targetDefaults === 'object') { + for (const name of Object.keys(targetDefaults).sort(compareStrings)) { + tasks.push({ + name, + command: `nx run-many --target=${name}`, + source: 'nx.json' + }); + } + } + } + + return tasks; +} + +function dedupeCommands(commands: CommandEntry[]): CommandEntry[] { + const seen = new Set(); + const deduped: CommandEntry[] = []; + for (const command of commands) { + if (seen.has(command.name)) { + continue; + } + seen.add(command.name); + deduped.push(command); + } + return deduped; +} diff --git a/src/core/repo-intelligence/glob.ts b/src/core/repo-intelligence/glob.ts new file mode 100644 index 0000000..e2fbfb1 --- /dev/null +++ b/src/core/repo-intelligence/glob.ts @@ -0,0 +1,73 @@ +export function matchGlob(pattern: string, filePath: string): boolean { + return matchPathPattern(pattern, filePath); +} + +export function matchPathPattern(pattern: string, filePath: string): boolean { + const normalizedPattern = normalizePath(pattern); + const normalizedPath = normalizePath(filePath); + + if (pattern.endsWith('/')) { + return normalizedPath === normalizedPattern || normalizedPath.startsWith(`${normalizedPattern}/`); + } + + if (normalizedPattern.endsWith('/**')) { + const prefix = normalizedPattern.slice(0, -3); + return normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`); + } + + return globToRegExp(normalizedPattern).test(normalizedPath); +} + +export function findLastMatchingRule( + filePath: string, + rules: T[] +): T | undefined { + let match: T | undefined; + for (const rule of rules) { + if (matchPathPattern(rule.pattern, filePath)) { + match = rule; + } + } + return match; +} + +function globToRegExp(pattern: string): RegExp { + let regex = '^'; + let index = 0; + + while (index < pattern.length) { + const char = pattern[index]; + if (char === '*' && pattern[index + 1] === '*') { + if (pattern[index + 2] === '/') { + regex += '(?:.*/)?'; + index += 3; + continue; + } + regex += '.*'; + index += 2; + continue; + } + if (char === '*') { + regex += '[^/]*'; + index += 1; + continue; + } + if (char === '?') { + regex += '[^/]'; + index += 1; + continue; + } + regex += escapeRegExpChar(char); + index += 1; + } + + return new RegExp(`${regex}$`); +} + +function escapeRegExpChar(char: string): string { + return /[$()*+.?[\\\]^{|}]/.test(char) ? `\\${char}` : char; +} + +function normalizePath(value: string): string { + return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/g, ''); +} diff --git a/src/core/repo-intelligence/risk.ts b/src/core/repo-intelligence/risk.ts new file mode 100644 index 0000000..cf56cc4 --- /dev/null +++ b/src/core/repo-intelligence/risk.ts @@ -0,0 +1,181 @@ +import type { KernelConfig } from '../config.js'; +import { findLastMatchingRule, matchGlob } from './glob.js'; +import type { + CodeownersRule, + CommandEntry, + ConfigRiskPathEntry, + OwnershipEntry, + RiskCommandEntry, + RiskPathEntry +} from './types.js'; +import { compareCommandEntries, compareStrings } from './utils.js'; + +export interface InferRiskOptions { + filePaths: string[]; + scripts: CommandEntry[]; + ignoredDirectories: string[]; + config: KernelConfig; + codeownersRules: CodeownersRule[]; +} + +export interface InferRiskResult { + highRiskPaths: RiskPathEntry[]; + destructiveCommands: RiskCommandEntry[]; + ownership: OwnershipEntry[]; + configRiskPaths: ConfigRiskPathEntry[]; +} + +export function inferRisk(options: InferRiskOptions): InferRiskResult { + const inferredPaths = options.filePaths.flatMap(classifyHighRiskPath).sort(compareRiskPaths); + const configRiskPaths = buildConfigRiskPaths(options.config); + const configMatchedPaths = matchConfigRiskPaths(options.filePaths, configRiskPaths); + const ownership = buildOwnership(options.filePaths, options.codeownersRules, options.config.maps.include_codeowners); + const ownedRiskPaths = ownership + .filter((entry) => !hasRiskPath(inferredPaths, entry.path) && !hasRiskPath(configMatchedPaths, entry.path)) + .map((entry) => ({ path: entry.path, reason: 'owned path' })); + + const highRiskPaths = dedupeRiskPaths([...inferredPaths, ...configMatchedPaths, ...ownedRiskPaths]).sort(compareRiskPaths); + const destructiveCommands = dedupeRiskCommands([ + ...options.scripts.flatMap(classifyDestructiveCommand), + ...classifyConfigDestructiveCommands(options.scripts, options.config) + ]).sort(compareRiskCommands); + + return { + highRiskPaths, + destructiveCommands, + ownership: ownership.sort(compareOwnership), + configRiskPaths: configRiskPaths.sort((left, right) => compareStrings(left.pattern, right.pattern)) + }; +} + +function buildConfigRiskPaths(config: KernelConfig): ConfigRiskPathEntry[] { + return config.risk.high_risk_paths.map((pattern) => ({ + pattern, + reason: 'configured high-risk path' + })); +} + +function matchConfigRiskPaths(filePaths: string[], configRiskPaths: ConfigRiskPathEntry[]): RiskPathEntry[] { + const matches: RiskPathEntry[] = []; + for (const filePath of filePaths) { + for (const configRiskPath of configRiskPaths) { + if (matchGlob(configRiskPath.pattern, filePath)) { + matches.push({ path: filePath, reason: configRiskPath.reason }); + } + } + } + return matches; +} + +function buildOwnership(filePaths: string[], rules: CodeownersRule[], includeCodeowners: boolean): OwnershipEntry[] { + if (!includeCodeowners || rules.length === 0) { + return []; + } + + const ownership: OwnershipEntry[] = []; + for (const filePath of filePaths) { + const match = findLastMatchingRule(filePath, rules); + if (!match) { + continue; + } + ownership.push({ + path: filePath, + owners: [...match.owners], + source: 'codeowners' + }); + } + return ownership; +} + +function classifyHighRiskPath(path: string): RiskPathEntry[] { + if (path.startsWith('.github/workflows/')) { + return [{ path, reason: 'CI workflow' }]; + } + if (path.startsWith('db/migrations/')) { + return [{ path, reason: 'database migration' }]; + } + if (path.startsWith('infra/')) { + return [{ path, reason: 'infrastructure' }]; + } + if (path.startsWith('auth/') || path.includes('/auth/')) { + return [{ path, reason: 'authentication' }]; + } + if (path.startsWith('billing/') || path.includes('/billing/')) { + return [{ path, reason: 'billing' }]; + } + + return []; +} + +function classifyDestructiveCommand(command: CommandEntry): RiskCommandEntry[] { + const script = command.script; + if (script.includes('npm publish') || script.includes('pnpm publish')) { + return [{ ...command, reason: 'package publishing' }]; + } + if (script.includes('git push --force')) { + return [{ ...command, reason: 'force push' }]; + } + if (script.includes('git reset --hard')) { + return [{ ...command, reason: 'destructive git reset' }]; + } + if (script.includes('rm -rf')) { + return [{ ...command, reason: 'recursive deletion' }]; + } + + return []; +} + +function classifyConfigDestructiveCommands(scripts: CommandEntry[], config: KernelConfig): RiskCommandEntry[] { + const matches: RiskCommandEntry[] = []; + for (const command of scripts) { + for (const pattern of config.risk.destructive_commands) { + if (command.script.includes(pattern) || command.command.includes(pattern)) { + matches.push({ ...command, reason: `matches configured destructive command: ${pattern}` }); + } + } + } + return matches; +} + +function hasRiskPath(entries: RiskPathEntry[], path: string): boolean { + return entries.some((entry) => entry.path === path); +} + +function dedupeRiskPaths(entries: RiskPathEntry[]): RiskPathEntry[] { + const seen = new Set(); + const deduped: RiskPathEntry[] = []; + for (const entry of entries) { + if (seen.has(entry.path)) { + continue; + } + seen.add(entry.path); + deduped.push(entry); + } + return deduped; +} + +function dedupeRiskCommands(entries: RiskCommandEntry[]): RiskCommandEntry[] { + const seen = new Set(); + const deduped: RiskCommandEntry[] = []; + for (const entry of entries) { + const key = `${entry.name}:${entry.reason}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(entry); + } + return deduped; +} + +function compareRiskPaths(left: RiskPathEntry, right: RiskPathEntry): number { + return compareStrings(left.path, right.path); +} + +function compareRiskCommands(left: RiskCommandEntry, right: RiskCommandEntry): number { + return compareCommandEntries(left, right); +} + +function compareOwnership(left: OwnershipEntry, right: OwnershipEntry): number { + return compareStrings(left.path, right.path); +} diff --git a/src/core/repo-intelligence/tests.ts b/src/core/repo-intelligence/tests.ts new file mode 100644 index 0000000..6aa151e --- /dev/null +++ b/src/core/repo-intelligence/tests.ts @@ -0,0 +1,105 @@ +import { basename } from 'node:path'; + +import type { CommandEntry, TestFramework } from './types.js'; +import { compareCommandEntries, compareStrings } from './utils.js'; + +const TEST_CONFIG_PATTERNS = [ + /^vitest\.config\.[cm]?[jt]sx?$/, + /^jest\.config\.[cm]?[jt]sx?$/, + /^playwright\.config\.[cm]?[jt]sx?$/, + /^cypress\.config\.[cm]?[jt]sx?$/, + /^mocha\.opts$/ +] as const; + +const E2E_DIRECTORY_NAMES = ['e2e', 'playwright', 'cypress'] as const; + +const TEST_FILE_PATTERNS = [ + 'tests/**', + 'test/**', + '__tests__/**', + '**/*.test.[cm]?[jt]sx?', + '**/*.spec.[cm]?[jt]sx?', + '**/*.e2e.[cm]?[jt]sx?' +] as const; + +export interface DetectTestsResult { + testFiles: string[]; + testCommands: CommandEntry[]; + frameworks: TestFramework[]; + configFiles: string[]; + e2ePaths: string[]; + patterns: string[]; +} + +export function detectTests(filePaths: string[], scripts: CommandEntry[]): DetectTestsResult { + const testFiles = filePaths.filter(isTestFile).sort(compareStrings); + const testCommands = scripts.filter((script) => script.name.includes('test')).sort(compareCommandEntries); + const configFiles = filePaths.filter(isTestConfigFile).sort(compareStrings); + const e2ePaths = detectE2ePaths(filePaths); + const frameworks = detectFrameworks(filePaths, scripts, configFiles); + + return { + testFiles, + testCommands, + frameworks: frameworks.sort(compareStrings) as TestFramework[], + configFiles, + e2ePaths, + patterns: [...TEST_FILE_PATTERNS] + }; +} + +export function isTestFile(path: string): boolean { + const fileName = basename(path); + return ( + path.includes('/__tests__/') || + path.startsWith('tests/') || + path.startsWith('test/') || + /\.test\.[cm]?[jt]sx?$/.test(fileName) || + /\.spec\.[cm]?[jt]sx?$/.test(fileName) || + /\.e2e\.[cm]?[jt]sx?$/.test(fileName) + ); +} + +function isTestConfigFile(path: string): boolean { + const fileName = basename(path); + return TEST_CONFIG_PATTERNS.some((pattern) => pattern.test(fileName)); +} + +function detectE2ePaths(filePaths: string[]): string[] { + const paths = new Set(); + for (const filePath of filePaths) { + for (const dirName of E2E_DIRECTORY_NAMES) { + if (filePath === dirName || filePath.startsWith(`${dirName}/`)) { + paths.add(dirName); + } + } + } + return [...paths].sort(compareStrings); +} + +function detectFrameworks( + filePaths: string[], + scripts: CommandEntry[], + configFiles: string[] +): TestFramework[] { + const frameworks = new Set(); + const haystack = [...filePaths, ...scripts.map((script) => script.script), ...configFiles].join('\n').toLowerCase(); + + if (haystack.includes('vitest') || configFiles.some((file) => file.includes('vitest.config'))) { + frameworks.add('vitest'); + } + if (haystack.includes('jest') || configFiles.some((file) => file.includes('jest.config'))) { + frameworks.add('jest'); + } + if (haystack.includes('playwright') || configFiles.some((file) => file.includes('playwright.config'))) { + frameworks.add('playwright'); + } + if (haystack.includes('cypress') || configFiles.some((file) => file.includes('cypress.config'))) { + frameworks.add('cypress'); + } + if (haystack.includes('mocha') || configFiles.some((file) => file.includes('mocha.opts'))) { + frameworks.add('mocha'); + } + + return [...frameworks]; +} diff --git a/src/core/repo-intelligence/types.ts b/src/core/repo-intelligence/types.ts new file mode 100644 index 0000000..0d925ce --- /dev/null +++ b/src/core/repo-intelligence/types.ts @@ -0,0 +1,68 @@ +export interface CommandEntry { + name: string; + command: string; + script: string; +} + +export type CommandSource = 'package.json' | 'makefile' | 'justfile' | 'kernel.yaml' | 'turbo' | 'nx'; + +export interface PackageScriptsEntry { + package: string; + path: string; + scripts: CommandEntry[]; +} + +export interface TaskRunnerTask { + name: string; + command: string; + source: string; +} + +export interface RepoFileEntry { + path: string; + sizeBytes: number; +} + +export interface WorkspacePackage { + name: string; + path: string; + private?: boolean; +} + +export interface MonorepoInfo { + tool: 'pnpm' | 'npm' | 'yarn' | null; + workspaceFile: string | null; +} + +export interface EntrypointEntry { + path: string; + kind: 'main' | 'module' | 'bin' | 'types'; +} + +export type TestFramework = 'vitest' | 'jest' | 'playwright' | 'cypress' | 'mocha'; + +export interface RiskPathEntry { + path: string; + reason: string; +} + +export interface RiskCommandEntry extends CommandEntry { + reason: string; +} + +export interface OwnershipEntry { + path: string; + owners: string[]; + source: 'codeowners' | 'config' | 'inferred'; +} + +export interface ConfigRiskPathEntry { + pattern: string; + reason: string; +} + +export interface CodeownersRule { + pattern: string; + owners: string[]; + source: string; +} diff --git a/src/core/repo-intelligence/utils.ts b/src/core/repo-intelligence/utils.ts new file mode 100644 index 0000000..59d57fe --- /dev/null +++ b/src/core/repo-intelligence/utils.ts @@ -0,0 +1,77 @@ +import { access, readFile } from 'node:fs/promises'; + +export interface PackageJson { + name?: unknown; + private?: unknown; + scripts?: Record; + workspaces?: unknown; + main?: unknown; + module?: unknown; + types?: unknown; + bin?: unknown; +} + +export function compareStrings(left: string, right: string): number { + return left.localeCompare(right, 'en'); +} + +export function compareCommandEntries(left: T, right: T): number { + return compareStrings(left.name, right.name); +} + +export async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return false; + } + throw error; + } +} + +export async function readPackageJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as PackageJson; +} + +export async function readPackageScripts( + packageJsonPath: string, + packageManager: string | null, + packageName: string +): Promise { + if (!(await pathExists(packageJsonPath)) || packageManager === null) { + return []; + } + + const packageJson = await readPackageJson(packageJsonPath); + const scripts = packageJson.scripts ?? {}; + return Object.entries(scripts) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string') + .map(([name, script]) => ({ + name, + command: buildPackageCommand(packageManager, packageName, name), + script + })) + .sort(compareCommandEntries); +} + +function buildPackageCommand(packageManager: string, packageName: string, scriptName: string): string { + if (packageName === 'root') { + return `${packageManager} ${scriptName}`; + } + if (packageManager === 'pnpm') { + return `pnpm --filter ${packageName} ${scriptName}`; + } + if (packageManager === 'npm') { + return `npm run ${scriptName} --workspace=${packageName}`; + } + if (packageManager === 'yarn') { + return `yarn workspace ${packageName} ${scriptName}`; + } + return `${packageManager} ${scriptName}`; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} diff --git a/src/core/repo-intelligence/workspaces.ts b/src/core/repo-intelligence/workspaces.ts new file mode 100644 index 0000000..c6022a9 --- /dev/null +++ b/src/core/repo-intelligence/workspaces.ts @@ -0,0 +1,161 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { parse as parseYaml } from 'yaml'; + +import type { EntrypointEntry, MonorepoInfo, WorkspacePackage } from './types.js'; +import { compareStrings, pathExists, readPackageJson } from './utils.js'; + +export interface DetectWorkspacesResult { + monorepo: MonorepoInfo; + packages: WorkspacePackage[]; + entrypoints: EntrypointEntry[]; +} + +export async function detectWorkspaces(rootDir: string): Promise { + const pnpmWorkspacePath = join(rootDir, 'pnpm-workspace.yaml'); + const packageJsonPath = join(rootDir, 'package.json'); + + let monorepo: MonorepoInfo = { tool: null, workspaceFile: null }; + let packagePaths: string[] = []; + const entrypoints = await readEntrypoints(packageJsonPath); + + if (await pathExists(pnpmWorkspacePath)) { + monorepo = { tool: 'pnpm', workspaceFile: 'pnpm-workspace.yaml' }; + packagePaths = await readPnpmWorkspacePackages(pnpmWorkspacePath); + } else if (await pathExists(packageJsonPath)) { + const packageJson = await readPackageJson(packageJsonPath); + const workspaces = packageJson.workspaces; + if (Array.isArray(workspaces)) { + monorepo = { tool: 'npm', workspaceFile: 'package.json' }; + packagePaths = workspaces.filter((entry): entry is string => typeof entry === 'string'); + } else if ( + workspaces && + typeof workspaces === 'object' && + 'packages' in workspaces && + Array.isArray((workspaces as { packages: unknown }).packages) + ) { + monorepo = { tool: 'npm', workspaceFile: 'package.json' }; + packagePaths = (workspaces as { packages: unknown[] }).packages.filter( + (entry): entry is string => typeof entry === 'string' + ); + } + if (await pathExists(join(rootDir, 'yarn.lock'))) { + monorepo.tool = 'yarn'; + } + } + + const packages: WorkspacePackage[] = []; + for (const pattern of packagePaths.sort(compareStrings)) { + const resolvedPackages = await resolveWorkspacePattern(rootDir, pattern); + packages.push(...resolvedPackages); + } + + return { + monorepo, + packages: dedupePackages(packages).sort((left, right) => compareStrings(left.path, right.path)), + entrypoints: entrypoints.sort((left, right) => compareStrings(left.path, right.path)) + }; +} + +async function readPnpmWorkspacePackages(workspacePath: string): Promise { + const raw = await readFile(workspacePath, 'utf8'); + const parsed = parseYaml(raw) as { packages?: unknown }; + if (!Array.isArray(parsed.packages)) { + return []; + } + return parsed.packages.filter((entry): entry is string => typeof entry === 'string'); +} + +async function resolveWorkspacePattern(rootDir: string, pattern: string): Promise { + if (!pattern.includes('*')) { + return [await readWorkspacePackage(rootDir, pattern)]; + } + + const normalizedPattern = pattern.replace(/\\/g, '/').replace(/\/+$/g, ''); + const wildcardIndex = normalizedPattern.indexOf('*'); + const baseDir = normalizedPattern.slice(0, wildcardIndex).replace(/\/+$/g, ''); + const suffix = normalizedPattern.slice(wildcardIndex + 1).replace(/^\//, ''); + const absoluteBase = join(rootDir, baseDir); + + if (!(await pathExists(absoluteBase))) { + return []; + } + + const { readdir } = await import('node:fs/promises'); + const entries = await readdir(absoluteBase, { withFileTypes: true }); + const packages: WorkspacePackage[] = []; + + for (const entry of entries.sort((left, right) => compareStrings(left.name, right.name))) { + if (!entry.isDirectory()) { + continue; + } + const relativePath = joinRelative(baseDir, entry.name, suffix); + const packageJsonPath = join(rootDir, relativePath, 'package.json'); + if (await pathExists(packageJsonPath)) { + packages.push(await readWorkspacePackage(rootDir, relativePath)); + } + } + + return packages; +} + +async function readWorkspacePackage(rootDir: string, relativePath: string): Promise { + const packageJsonPath = join(rootDir, relativePath, 'package.json'); + const packageJson = await readPackageJson(packageJsonPath); + return { + name: typeof packageJson.name === 'string' ? packageJson.name : relativePath, + path: relativePath.replace(/\\/g, '/'), + private: packageJson.private === true ? true : undefined + }; +} + +async function readEntrypoints(packageJsonPath: string): Promise { + if (!(await pathExists(packageJsonPath))) { + return []; + } + + const packageJson = await readPackageJson(packageJsonPath); + const entrypoints: EntrypointEntry[] = []; + + if (typeof packageJson.main === 'string') { + entrypoints.push({ path: packageJson.main, kind: 'main' }); + } + if (typeof packageJson.module === 'string') { + entrypoints.push({ path: packageJson.module, kind: 'module' }); + } + if (typeof packageJson.types === 'string') { + entrypoints.push({ path: packageJson.types, kind: 'types' }); + } + if (typeof packageJson.bin === 'string') { + entrypoints.push({ path: packageJson.bin, kind: 'bin' }); + } else if (packageJson.bin && typeof packageJson.bin === 'object') { + for (const binPath of Object.values(packageJson.bin)) { + if (typeof binPath === 'string') { + entrypoints.push({ path: binPath, kind: 'bin' }); + } + } + } + + return entrypoints; +} + +function dedupePackages(packages: WorkspacePackage[]): WorkspacePackage[] { + const seen = new Set(); + const deduped: WorkspacePackage[] = []; + for (const pkg of packages) { + if (seen.has(pkg.path)) { + continue; + } + seen.add(pkg.path); + deduped.push(pkg); + } + return deduped; +} + +function joinRelative(...parts: string[]): string { + return parts + .flatMap((part) => part.split(/[\\/]+/)) + .filter(Boolean) + .join('/'); +} diff --git a/src/core/validate.ts b/src/core/validate.ts index 11d9108..e910d7b 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -2,6 +2,7 @@ import { access, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { getAdapter } from '../adapters/index.js'; +import { loadCanonicalSkills } from '../adapters/canonical-skills.js'; import type { KernelAdapter } from '../adapters/types.js'; import { renderGeneratedAdapterFile } from './adapter-compiler.js'; import { KernelConfigError, loadKernelConfig, type KernelConfig } from './config.js'; @@ -9,6 +10,8 @@ import { INIT_DIRECTORIES } from './init.js'; import { formatKernelJsonResult } from './json-output.js'; import { GENERATED_FILE_HEADER } from './manual-sections.js'; import { lintKernelSkills, type SkillLintIssueCode } from './skills.js'; +import { checkPolicy } from './policy/check.js'; +import { hasPolicyFiles } from './policy/loader.js'; export type ValidationSeverity = 'error' | 'warning'; export type ValidationStatus = 'pass' | 'warn' | 'fail'; @@ -23,10 +26,16 @@ export interface ValidationIssue { | 'invalid_config' | 'missing_required_directory' | 'missing_map_file' + | 'stale_map_version' | 'missing_generated_header' | 'missing_evidence_for_current_task' | 'missing_adapter_output' | 'stale_generated_file' + | 'missing_policy_file' + | 'policy_command_blocked' + | 'policy_path_review_required' + | 'insufficient_verification_level' + | 'missing_ci_check' | SkillLintIssueCode; severity: ValidationSeverity; path: string; @@ -42,7 +51,18 @@ export interface ValidationResult { } const REQUIRED_MAP_FILES = ['repo.json', 'commands.json', 'tests.json', 'risk.json'] as const; -const ADAPTER_TARGETS = ['codex', 'claude', 'cursor', 'kiro', 'github-copilot'] as const; +const ADAPTER_TARGETS = [ + 'codex', + 'claude', + 'cursor', + 'kiro', + 'github-copilot', + 'gemini', + 'zed', + 'opencode', + 'windsurf', + 'junie' +] as const; export async function validateKernel( rootDir: string = process.cwd(), @@ -77,9 +97,11 @@ export async function validateKernel( issues.push(...(await validateRequiredDirectories(rootDir))); issues.push(...(await validateMapSet(rootDir))); + issues.push(...(await validateMapVersions(rootDir, config))); issues.push(...(await validateAdapterOutputs(rootDir, config, getEnabledAdapters(config)))); issues.push(...(await validateSkills(rootDir, config))); issues.push(...(await validateCurrentTaskEvidence(rootDir))); + issues.push(...(await validatePolicies(rootDir, strict))); return buildValidationResult(issues, strict); } @@ -124,6 +146,48 @@ async function validateRequiredDirectories(rootDir: string): Promise { + const issues: ValidationIssue[] = []; + const mapsDir = join(rootDir, config.canonical.maps_dir); + + for (const file of REQUIRED_MAP_FILES) { + const mapPath = join(mapsDir, file); + if (!(await pathExists(mapPath))) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(mapPath, 'utf8')); + } catch { + continue; + } + + if ( + typeof parsed === 'object' && + parsed !== null && + 'version' in parsed && + (parsed as { version: unknown }).version === 1 + ) { + issues.push({ + code: 'stale_map_version', + severity: 'warning', + path: joinRelative(config.canonical.maps_dir, file), + message: `Map file is version 1; regenerate with \`kernel map --force\` to upgrade to version 2.` + }); + } + } + + return issues; +} + +function joinRelative(...parts: string[]): string { + return parts + .flatMap((part) => part.split(/[\\/]+/)) + .filter(Boolean) + .join('/'); +} + async function validateMapSet(rootDir: string): Promise { const existing = new Set(); @@ -152,9 +216,10 @@ async function validateAdapterOutputs( adapters: KernelAdapter[] ): Promise { const issues: ValidationIssue[] = []; + const canonicalSkills = await loadCanonicalSkills(rootDir, config); for (const adapter of adapters) { - const outputs = adapter.render({ config }); + const outputs = adapter.render({ config, canonicalSkills }); for (const output of outputs) { const absolutePath = join(rootDir, output.path); if (!(await pathExists(absolutePath))) { @@ -201,10 +266,87 @@ function getEnabledAdapters(config: KernelConfig): KernelAdapter[] { return config.adapters.github_copilot; } - return config.adapters[target]; + return config.adapters[target as keyof typeof config.adapters]; }).map((target) => getAdapter(target)); } +async function validatePolicies(rootDir: string, strict: boolean): Promise { + const issues: ValidationIssue[] = []; + + if (!(await hasPolicyFiles(rootDir))) { + issues.push({ + code: 'missing_policy_file', + severity: 'warning', + path: '.agent/policies/policy-gate.yaml', + message: 'No policy files found in .agent/policies/.' + }); + } + + const hasCurrentTask = await pathExists(join(rootDir, '.agent', 'state', 'current-task.md')); + const policyResult = await checkPolicy({ + rootDir, + strict, + ci: true, + task: hasCurrentTask ? 'current' : undefined + }); + + for (const violation of policyResult.violations) { + if (violation.code === 'missing_policy_file') { + continue; + } + + const code = mapPolicyViolationCode(violation.code); + if (!code) { + continue; + } + + issues.push({ + code, + severity: getPolicyIssueSeverity(violation, strict), + path: violation.path, + message: violation.message + }); + } + + return issues; +} + +function mapPolicyViolationCode( + code: string +): + | 'policy_command_blocked' + | 'policy_path_review_required' + | 'insufficient_verification_level' + | 'missing_ci_check' + | null { + if (code === 'policy_command_blocked') { + return 'policy_command_blocked'; + } + if (code === 'policy_path_review' || code === 'policy_path_block') { + return 'policy_path_review_required'; + } + if (code === 'insufficient_verification_level') { + return 'insufficient_verification_level'; + } + if (code === 'missing_ci_check') { + return 'missing_ci_check'; + } + return null; +} + +function getPolicyIssueSeverity( + violation: { code: string; severity: 'error' | 'warning'; policyClass: string }, + strict: boolean +): ValidationSeverity { + if (violation.code === 'policy_command_blocked' || violation.code === 'policy_path_block') { + return strict ? 'error' : 'warning'; + } + if (violation.code === 'policy_command_review' && violation.policyClass === 'block') { + return strict ? 'error' : 'warning'; + } + return 'warning'; +} + async function validateCurrentTaskEvidence(rootDir: string): Promise { const currentTaskPath = join(rootDir, '.agent', 'state', 'current-task.md'); if (!(await pathExists(currentTaskPath))) { diff --git a/tests/artifacts.test.ts b/tests/artifacts.test.ts index 937713f..84f9dff 100644 --- a/tests/artifacts.test.ts +++ b/tests/artifacts.test.ts @@ -4,12 +4,14 @@ import { join } from 'node:path'; import { afterEach, describe, expect, test } from 'vitest'; import { + addEvidenceCommand, createEvidenceLedger, createHandoffPacket, createTaskContract, renderEvidenceLedger, renderHandoffPacket, - renderTaskContract + renderTaskContract, + showTaskContract } from '../src/core/artifacts.js'; import { KernelFileExistsError } from '../src/core/fs.js'; @@ -163,4 +165,62 @@ describe('artifact writers', () => { await expect(readText(join(rootDir, '.agent', 'evidence', 'explicit-task.md'))).rejects.toThrow(); await expect(readText(join(rootDir, '.agent', 'handoffs', 'explicit-task.md'))).rejects.toThrow(); }); + + test('shows the current task contract', async () => { + const rootDir = await copyFixture('artifacts-current-task'); + + const view = await showTaskContract(rootDir); + + expect(view.id).toBe('current-task'); + expect(view.type).toBe('bugfix'); + expect(view.goal).toBe('Keep a known current task for artifact tests.'); + expect(view.relativePath).toBe('.agent/state/current-task.md'); + expect(view.content).toContain('# Task Contract: current-task'); + }); + + test('shows a task contract by id', async () => { + const rootDir = await copyFixture('artifacts-empty'); + + await createTaskContract(rootDir, { + id: 'explicit-task', + type: 'feature', + goal: 'Show task by id' + }); + + const view = await showTaskContract(rootDir, { id: 'explicit-task' }); + + expect(view.relativePath).toBe('.agent/contracts/explicit-task.md'); + expect(view.id).toBe('explicit-task'); + expect(view.goal).toBe('Show task by id'); + }); + + test('appends a verification command to an evidence ledger', async () => { + const rootDir = await copyFixture('artifacts-current-task'); + + await createEvidenceLedger(rootDir, { task: 'current', claim: 'Verified artifact commands' }); + const result = await addEvidenceCommand(rootDir, { + task: 'current', + command: 'pnpm test', + exitCode: '0', + result: 'pass', + notes: 'artifacts suite' + }); + + expect(result.taskId).toBe('current-task'); + expect(result.files[0]?.relativePath).toBe('.agent/evidence/current-task.md'); + await expect(readText(join(rootDir, '.agent', 'evidence', 'current-task.md'))).resolves.toContain( + '| pnpm test | 0 | pass | artifacts suite |' + ); + }); + + test('refuses to append a command when the evidence ledger is missing', async () => { + const rootDir = await copyFixture('artifacts-current-task'); + + await expect( + addEvidenceCommand(rootDir, { + task: 'current', + command: 'pnpm test' + }) + ).rejects.toThrow('Evidence ledger not found'); + }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 349b74c..a57d4c9 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -716,4 +716,31 @@ describe('Kernel CLI JSON error envelopes', () => { } }); }); + + test('supports kernel policy check --help', () => { + const output = helpFor(['policy', 'check', '--help']); + + expect(output).toContain('Usage: kernel policy check'); + expect(output).toContain('--command'); + expect(output).toContain('--ci'); + }); + + test('exits with code 1 when a blocked command is classified', async () => { + const rootDir = await copyFixture('policy-basic'); + + const result = await runCli(['policy', 'check', '--command', 'npm publish'], rootDir); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('policy_command_blocked'); + }); + + test('prints JSON policy check results', async () => { + const rootDir = await copyFixture('policy-review-path'); + + const result = await runCli(['policy', 'check', '--path', 'src/auth/login.ts', '--json'], rootDir); + const parsed = JSON.parse(result.stdout) as { status: string; violations: Array<{ code: string }> }; + + expect(parsed.status).toBe('warn'); + expect(parsed.violations.some((violation) => violation.code === 'policy_path_review')).toBe(true); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index d1b0de1..79c27e6 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -46,13 +46,26 @@ describe('loadKernelConfig', () => { claude: true, cursor: true, kiro: true, - github_copilot: true + github_copilot: true, + gemini: false, + zed: false, + opencode: false, + windsurf: false, + junie: false }, skills: { generated_set: 'mvp' }, eval: { default_runner: 'static' + }, + commands: {}, + risk: { + high_risk_paths: [], + destructive_commands: [] + }, + maps: { + include_codeowners: true } }); }); @@ -149,4 +162,33 @@ describe('loadKernelConfig', () => { await expect(loadKernelConfig(rootDir)).rejects.toBeInstanceOf(KernelConfigError); }); + + test('loads optional commands and risk blocks', async () => { + const rootDir = await createTempRepo(); + await mkdir(join(rootDir, '.agent'), { recursive: true }); + await writeFile( + join(rootDir, '.agent', 'kernel.yaml'), + [ + 'version: 1', + 'commands:', + ' test: pnpm test', + 'risk:', + ' high_risk_paths:', + ' - src/core/**', + ' destructive_commands:', + ' - npm publish', + 'maps:', + ' include_codeowners: false', + '' + ].join('\n'), + 'utf8' + ); + + const config = await loadKernelConfig(rootDir); + + expect(config.commands).toEqual({ test: 'pnpm test' }); + expect(config.risk.high_risk_paths).toEqual(['src/core/**']); + expect(config.risk.destructive_commands).toEqual(['npm publish']); + expect(config.maps.include_codeowners).toBe(false); + }); }); diff --git a/tests/fixtures/compile-all-basic/.agent/skills/kernel-core/SKILL.md b/tests/fixtures/compile-all-basic/.agent/skills/kernel-core/SKILL.md new file mode 100644 index 0000000..9906ca9 --- /dev/null +++ b/tests/fixtures/compile-all-basic/.agent/skills/kernel-core/SKILL.md @@ -0,0 +1,24 @@ +--- +name: kernel-core +description: Use when coordinating non-trivial coding-agent tasks. Do not use for trivial one-line edits unless the touched area is high-risk. +--- + +# kernel-core + +## Purpose + +Coordinate task contracts, verification, evidence, and handoff discipline for non-trivial agent work. + +## Trigger + +Use when a coding-agent task requires durable context, quality gates, or evidence-backed completion. + +## Workflow + +1. Read the current task contract. +2. Select relevant quality gates. +3. Record evidence before completion. + +## Output + +Writes or updates `.agent/state/current-task.md` and `.agent/evidence/` artifacts when filesystem access is available. diff --git a/tests/fixtures/maps-codeowners/.agent/kernel.yaml b/tests/fixtures/maps-codeowners/.agent/kernel.yaml new file mode 100644 index 0000000..814871e --- /dev/null +++ b/tests/fixtures/maps-codeowners/.agent/kernel.yaml @@ -0,0 +1,8 @@ +version: 1 +project: + name: maps-codeowners-fixture +risk: + high_risk_paths: + - src/auth/** + destructive_commands: + - rm -rf diff --git a/tests/fixtures/maps-codeowners/.github/CODEOWNERS b/tests/fixtures/maps-codeowners/.github/CODEOWNERS new file mode 100644 index 0000000..58a7aaa --- /dev/null +++ b/tests/fixtures/maps-codeowners/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Security-sensitive areas +/src/auth/ @security-team @platform-leads diff --git a/tests/fixtures/maps-codeowners/package.json b/tests/fixtures/maps-codeowners/package.json new file mode 100644 index 0000000..0263ba2 --- /dev/null +++ b/tests/fixtures/maps-codeowners/package.json @@ -0,0 +1,4 @@ +{ + "name": "maps-codeowners-fixture", + "version": "1.0.0" +} diff --git a/tests/fixtures/maps-codeowners/src/auth/login.ts b/tests/fixtures/maps-codeowners/src/auth/login.ts new file mode 100644 index 0000000..c87f597 --- /dev/null +++ b/tests/fixtures/maps-codeowners/src/auth/login.ts @@ -0,0 +1,3 @@ +export function login(): void { + // fixture +} diff --git a/tests/fixtures/maps-makefile/Makefile b/tests/fixtures/maps-makefile/Makefile new file mode 100644 index 0000000..917d951 --- /dev/null +++ b/tests/fixtures/maps-makefile/Makefile @@ -0,0 +1,10 @@ +.PHONY: build test lint + +build: + tsc -p tsconfig.json + +test: + vitest run + +lint: + eslint . diff --git a/tests/fixtures/maps-makefile/package.json b/tests/fixtures/maps-makefile/package.json new file mode 100644 index 0000000..57e436b --- /dev/null +++ b/tests/fixtures/maps-makefile/package.json @@ -0,0 +1,7 @@ +{ + "name": "maps-makefile-fixture", + "version": "1.0.0", + "scripts": { + "test": "vitest run" + } +} diff --git a/tests/fixtures/maps-monorepo/package.json b/tests/fixtures/maps-monorepo/package.json new file mode 100644 index 0000000..117b080 --- /dev/null +++ b/tests/fixtures/maps-monorepo/package.json @@ -0,0 +1,10 @@ +{ + "name": "maps-monorepo-root", + "version": "1.0.0", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run" + } +} diff --git a/tests/fixtures/maps-monorepo/packages/app/package.json b/tests/fixtures/maps-monorepo/packages/app/package.json new file mode 100644 index 0000000..5ef61ac --- /dev/null +++ b/tests/fixtures/maps-monorepo/packages/app/package.json @@ -0,0 +1,8 @@ +{ + "name": "@maps-monorepo/app", + "version": "1.0.0", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run" + } +} diff --git a/tests/fixtures/maps-monorepo/packages/app/src/index.ts b/tests/fixtures/maps-monorepo/packages/app/src/index.ts new file mode 100644 index 0000000..bed29d2 --- /dev/null +++ b/tests/fixtures/maps-monorepo/packages/app/src/index.ts @@ -0,0 +1 @@ +export const app = true; diff --git a/tests/fixtures/maps-monorepo/packages/lib/package.json b/tests/fixtures/maps-monorepo/packages/lib/package.json new file mode 100644 index 0000000..8bbfc38 --- /dev/null +++ b/tests/fixtures/maps-monorepo/packages/lib/package.json @@ -0,0 +1,7 @@ +{ + "name": "@maps-monorepo/lib", + "version": "1.0.0", + "scripts": { + "test": "vitest run" + } +} diff --git a/tests/fixtures/maps-monorepo/packages/lib/src/index.ts b/tests/fixtures/maps-monorepo/packages/lib/src/index.ts new file mode 100644 index 0000000..7b8c293 --- /dev/null +++ b/tests/fixtures/maps-monorepo/packages/lib/src/index.ts @@ -0,0 +1 @@ +export const lib = true; diff --git a/tests/fixtures/maps-monorepo/pnpm-lock.yaml b/tests/fixtures/maps-monorepo/pnpm-lock.yaml new file mode 100644 index 0000000..b0a073a --- /dev/null +++ b/tests/fixtures/maps-monorepo/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' diff --git a/tests/fixtures/maps-monorepo/pnpm-workspace.yaml b/tests/fixtures/maps-monorepo/pnpm-workspace.yaml new file mode 100644 index 0000000..18ec407 --- /dev/null +++ b/tests/fixtures/maps-monorepo/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/tests/fixtures/maps-vitest/e2e/login.spec.ts b/tests/fixtures/maps-vitest/e2e/login.spec.ts new file mode 100644 index 0000000..4d038bb --- /dev/null +++ b/tests/fixtures/maps-vitest/e2e/login.spec.ts @@ -0,0 +1 @@ +// e2e fixture placeholder diff --git a/tests/fixtures/maps-vitest/package.json b/tests/fixtures/maps-vitest/package.json new file mode 100644 index 0000000..6c09dd3 --- /dev/null +++ b/tests/fixtures/maps-vitest/package.json @@ -0,0 +1,12 @@ +{ + "name": "maps-vitest-fixture", + "version": "1.0.0", + "scripts": { + "test": "vitest run", + "test:e2e": "playwright test" + }, + "devDependencies": { + "vitest": "^4.0.0", + "playwright": "^1.0.0" + } +} diff --git a/tests/fixtures/maps-vitest/src/__tests__/app.test.ts b/tests/fixtures/maps-vitest/src/__tests__/app.test.ts new file mode 100644 index 0000000..fe177c9 --- /dev/null +++ b/tests/fixtures/maps-vitest/src/__tests__/app.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, test } from 'vitest'; + +describe('app', () => { + test('works', () => { + expect(true).toBe(true); + }); +}); diff --git a/tests/fixtures/maps-vitest/vitest.config.ts b/tests/fixtures/maps-vitest/vitest.config.ts new file mode 100644 index 0000000..0b05bc0 --- /dev/null +++ b/tests/fixtures/maps-vitest/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/tests/fixtures/policy-basic/.agent/kernel.yaml b/tests/fixtures/policy-basic/.agent/kernel.yaml new file mode 100644 index 0000000..ac51784 --- /dev/null +++ b/tests/fixtures/policy-basic/.agent/kernel.yaml @@ -0,0 +1,3 @@ +version: 1 +project: + name: policy-basic diff --git a/tests/fixtures/policy-basic/.agent/policies/policy-gate.yaml b/tests/fixtures/policy-basic/.agent/policies/policy-gate.yaml new file mode 100644 index 0000000..045d6d9 --- /dev/null +++ b/tests/fixtures/policy-basic/.agent/policies/policy-gate.yaml @@ -0,0 +1,18 @@ +version: 1 +commands: + - class: block + match: npm publish + reason: package publishing +paths: + - pattern: src/core/** + class: review + reason: core runtime + min_verification: L3 +escalation: + by_task_type: + migration: L5 + by_path: [] +ci: + provider: github-actions + required_checks: + - pnpm test diff --git a/tests/fixtures/policy-blocked-command/.agent/maps/commands.json b/tests/fixtures/policy-blocked-command/.agent/maps/commands.json new file mode 100644 index 0000000..c94ea14 --- /dev/null +++ b/tests/fixtures/policy-blocked-command/.agent/maps/commands.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "packageManager": "npm", + "scripts": [ + { + "name": "release", + "command": "npm release", + "script": "npm publish" + } + ], + "sources": [], + "kernelCommands": [], + "packageScripts": [], + "taskRunnerTasks": [] +} diff --git a/tests/fixtures/policy-blocked-command/.agent/policies/policy-gate.yaml b/tests/fixtures/policy-blocked-command/.agent/policies/policy-gate.yaml new file mode 100644 index 0000000..0a02c0e --- /dev/null +++ b/tests/fixtures/policy-blocked-command/.agent/policies/policy-gate.yaml @@ -0,0 +1,11 @@ +version: 1 +commands: + - class: block + match: npm publish +paths: [] +escalation: + by_task_type: {} + by_path: [] +ci: + provider: github-actions + required_checks: [] diff --git a/tests/fixtures/policy-ci-missing/.agent/policies/policy-gate.yaml b/tests/fixtures/policy-ci-missing/.agent/policies/policy-gate.yaml new file mode 100644 index 0000000..70310a1 --- /dev/null +++ b/tests/fixtures/policy-ci-missing/.agent/policies/policy-gate.yaml @@ -0,0 +1,11 @@ +version: 1 +commands: [] +paths: [] +escalation: + by_task_type: {} + by_path: [] +ci: + provider: github-actions + required_checks: + - pnpm test + - pnpm lint diff --git a/tests/fixtures/policy-ci-missing/.github/workflows/ci.yml b/tests/fixtures/policy-ci-missing/.github/workflows/ci.yml new file mode 100644 index 0000000..ae445ed --- /dev/null +++ b/tests/fixtures/policy-ci-missing/.github/workflows/ci.yml @@ -0,0 +1,12 @@ +name: CI + +on: + push: + branches: + - main + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - run: pnpm test diff --git a/tests/fixtures/policy-escalation/.agent/evidence/migration-task.md b/tests/fixtures/policy-escalation/.agent/evidence/migration-task.md new file mode 100644 index 0000000..ed1f5bb --- /dev/null +++ b/tests/fixtures/policy-escalation/.agent/evidence/migration-task.md @@ -0,0 +1,15 @@ +# Evidence Ledger: migration-task + +## Claim + +Migration applied. + +## Commands run + +| Command | Exit code | Result | Notes | +|---|---:|---|---| +| pnpm test | 0 | pass | targeted | + +## Completion status + +partially verified diff --git a/tests/fixtures/policy-escalation/.agent/policies/policy-gate.yaml b/tests/fixtures/policy-escalation/.agent/policies/policy-gate.yaml new file mode 100644 index 0000000..9475aad --- /dev/null +++ b/tests/fixtures/policy-escalation/.agent/policies/policy-gate.yaml @@ -0,0 +1,10 @@ +version: 1 +commands: [] +paths: [] +escalation: + by_task_type: + migration: L5 + by_path: [] +ci: + provider: github-actions + required_checks: [] diff --git a/tests/fixtures/policy-escalation/.agent/state/current-task.md b/tests/fixtures/policy-escalation/.agent/state/current-task.md new file mode 100644 index 0000000..82d488f --- /dev/null +++ b/tests/fixtures/policy-escalation/.agent/state/current-task.md @@ -0,0 +1,14 @@ +# Task Contract: migration-task + +Type: migration + +Goal: Apply schema migration. + +Risk zones: +- db/migrations/ + +Verification: +- pnpm test + +Done when: +- Migration applied. diff --git a/tests/fixtures/policy-review-path/.agent/policies/policy-gate.yaml b/tests/fixtures/policy-review-path/.agent/policies/policy-gate.yaml new file mode 100644 index 0000000..26096f4 --- /dev/null +++ b/tests/fixtures/policy-review-path/.agent/policies/policy-gate.yaml @@ -0,0 +1,12 @@ +version: 1 +commands: [] +paths: + - pattern: src/auth/** + class: review + reason: authentication +escalation: + by_task_type: {} + by_path: [] +ci: + provider: github-actions + required_checks: [] diff --git a/tests/fixtures/validate-adapters/.agent/policies/policy-gate.yaml b/tests/fixtures/validate-adapters/.agent/policies/policy-gate.yaml new file mode 100644 index 0000000..f17f35f --- /dev/null +++ b/tests/fixtures/validate-adapters/.agent/policies/policy-gate.yaml @@ -0,0 +1,11 @@ +version: 1 +commands: [] +paths: [] +escalation: + by_task_type: + feature: L0 + bugfix: L0 + by_path: [] +ci: + provider: github-actions + required_checks: [] diff --git a/tests/fixtures/validate-valid/.agent/maps/commands.json b/tests/fixtures/validate-valid/.agent/maps/commands.json index 61a2092..6da7b5b 100644 --- a/tests/fixtures/validate-valid/.agent/maps/commands.json +++ b/tests/fixtures/validate-valid/.agent/maps/commands.json @@ -1,3 +1,9 @@ { - "version": 1 + "version": 2, + "packageManager": null, + "scripts": [], + "sources": [], + "kernelCommands": [], + "packageScripts": [], + "taskRunnerTasks": [] } diff --git a/tests/fixtures/validate-valid/.agent/maps/repo.json b/tests/fixtures/validate-valid/.agent/maps/repo.json index 61a2092..cf1d251 100644 --- a/tests/fixtures/validate-valid/.agent/maps/repo.json +++ b/tests/fixtures/validate-valid/.agent/maps/repo.json @@ -1,3 +1,16 @@ { - "version": 1 + "version": 2, + "files": [], + "directories": [], + "ignoredDirectories": [], + "summary": { + "fileCount": 0, + "directoryCount": 0 + }, + "monorepo": { + "tool": null, + "workspaceFile": null + }, + "packages": [], + "entrypoints": [] } diff --git a/tests/fixtures/validate-valid/.agent/maps/risk.json b/tests/fixtures/validate-valid/.agent/maps/risk.json index 61a2092..5b8fb62 100644 --- a/tests/fixtures/validate-valid/.agent/maps/risk.json +++ b/tests/fixtures/validate-valid/.agent/maps/risk.json @@ -1,3 +1,8 @@ { - "version": 1 + "version": 2, + "highRiskPaths": [], + "destructiveCommands": [], + "ignoredDirectories": [], + "ownership": [], + "configRiskPaths": [] } diff --git a/tests/fixtures/validate-valid/.agent/maps/tests.json b/tests/fixtures/validate-valid/.agent/maps/tests.json index 61a2092..d2a176d 100644 --- a/tests/fixtures/validate-valid/.agent/maps/tests.json +++ b/tests/fixtures/validate-valid/.agent/maps/tests.json @@ -1,3 +1,9 @@ { - "version": 1 + "version": 2, + "testFiles": [], + "testCommands": [], + "frameworks": [], + "configFiles": [], + "e2ePaths": [], + "patterns": [] } diff --git a/tests/fixtures/validate-valid/.agent/policies/policy-gate.yaml b/tests/fixtures/validate-valid/.agent/policies/policy-gate.yaml new file mode 100644 index 0000000..f17f35f --- /dev/null +++ b/tests/fixtures/validate-valid/.agent/policies/policy-gate.yaml @@ -0,0 +1,11 @@ +version: 1 +commands: [] +paths: [] +escalation: + by_task_type: + feature: L0 + bugfix: L0 + by_path: [] +ci: + provider: github-actions + required_checks: [] diff --git a/tests/fixtures/validate-warnings/.agent/maps/repo.json b/tests/fixtures/validate-warnings/.agent/maps/repo.json index 61a2092..cf1d251 100644 --- a/tests/fixtures/validate-warnings/.agent/maps/repo.json +++ b/tests/fixtures/validate-warnings/.agent/maps/repo.json @@ -1,3 +1,16 @@ { - "version": 1 + "version": 2, + "files": [], + "directories": [], + "ignoredDirectories": [], + "summary": { + "fileCount": 0, + "directoryCount": 0 + }, + "monorepo": { + "tool": null, + "workspaceFile": null + }, + "packages": [], + "entrypoints": [] } diff --git a/tests/fixtures/validate-warnings/.agent/policies/policy-gate.yaml b/tests/fixtures/validate-warnings/.agent/policies/policy-gate.yaml new file mode 100644 index 0000000..f17f35f --- /dev/null +++ b/tests/fixtures/validate-warnings/.agent/policies/policy-gate.yaml @@ -0,0 +1,11 @@ +version: 1 +commands: [] +paths: [] +escalation: + by_task_type: + feature: L0 + bugfix: L0 + by_path: [] +ci: + provider: github-actions + required_checks: [] diff --git a/tests/gemini-adapter.test.ts b/tests/gemini-adapter.test.ts new file mode 100644 index 0000000..37abfbd --- /dev/null +++ b/tests/gemini-adapter.test.ts @@ -0,0 +1,36 @@ +import { cp, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { getAdapter } from '../src/adapters/index.js'; +import { compileAdapters } from '../src/core/adapter-compiler.js'; +import { GENERATED_FILE_HEADER } from '../src/core/manual-sections.js'; + +const tempDirs: string[] = []; + +async function copyFixture(name: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `kernel-gemini-${name}-`)); + tempDirs.push(dir); + await cp(join(process.cwd(), 'tests', 'fixtures', name), dir, { recursive: true }); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('gemini adapter', () => { + test('renders GEMINI.md and .gemini/settings.json', async () => { + const rootDir = await copyFixture('compile-all-basic'); + + const result = await compileAdapters(rootDir, [getAdapter('gemini')], { dryRun: true }); + + expect(result.files.map((file) => file.relativePath)).toEqual(['GEMINI.md', '.gemini/settings.json']); + expect(result.files[0]?.content).toContain(`${GENERATED_FILE_HEADER}`); + expect(result.files[0]?.content).toContain('# GEMINI.md'); + expect(result.files[0]?.content).toContain('No contract, no implementation'); + expect(result.files[1]?.content).toContain('"instructionsFile": "GEMINI.md"'); + expect(result.files[1]?.content).toContain('"canonicalAgentDir": ".agent"'); + }); +}); diff --git a/tests/init.test.ts b/tests/init.test.ts index edbecaa..663d1a4 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -43,8 +43,12 @@ describe('initializeKernel', () => { '.agent/adapters', '.agent/evals' ]); - expect(result.files.map((entry) => entry.relativePath)).toEqual(['.agent/kernel.yaml', 'AGENTS.md']); - expect(result.files.map((entry) => entry.action)).toEqual(['created', 'created']); + expect(result.files.map((entry) => entry.relativePath)).toEqual([ + '.agent/kernel.yaml', + '.agent/policies/policy-gate.yaml', + 'AGENTS.md' + ]); + expect(result.files.map((entry) => entry.action)).toEqual(['created', 'created', 'created']); await expect(readText(join(rootDir, '.agent', 'kernel.yaml'))).resolves.toContain('overwrite: false'); await expect(readText(join(rootDir, '.agent', 'kernel.yaml'))).resolves.toContain('github_copilot: true'); @@ -58,7 +62,7 @@ describe('initializeKernel', () => { const result = await initializeKernel(rootDir, { dryRun: true }); expect(result.directories.every((entry) => entry.action === 'would-create')).toBe(true); - expect(result.files.map((entry) => entry.action)).toEqual(['would-create', 'would-create']); + expect(result.files.map((entry) => entry.action)).toEqual(['would-create', 'would-create', 'would-create']); await expect(readText(join(rootDir, '.agent', 'kernel.yaml'))).rejects.toThrow(); await expect(readText(join(rootDir, 'AGENTS.md'))).rejects.toThrow(); }); @@ -98,4 +102,24 @@ describe('initializeKernel', () => { await expect(readText(join(rootDir, 'AGENTS.md'))).rejects.toThrow(); }); + + test('seeds adapter flags when --adapters is provided', async () => { + const rootDir = await copyFixture('init-empty'); + + await initializeKernel(rootDir, { adapters: 'codex,gemini' }); + + const config = await readText(join(rootDir, '.agent', 'kernel.yaml')); + expect(config).toContain('codex: true'); + expect(config).toContain('gemini: true'); + expect(config).toContain('claude: false'); + expect(config).toContain('github_copilot: false'); + }); + + test('rejects unknown adapter targets during init', async () => { + const rootDir = await copyFixture('init-empty'); + + await expect(initializeKernel(rootDir, { adapters: 'codex,unknown-ade' })).rejects.toThrow( + 'Unknown adapter target(s): unknown-ade' + ); + }); }); diff --git a/tests/maps.test.ts b/tests/maps.test.ts index 24724b0..612de6f 100644 --- a/tests/maps.test.ts +++ b/tests/maps.test.ts @@ -32,11 +32,15 @@ afterEach(async () => { }); describe('scanRepositoryMaps', () => { - test('builds deterministic maps and ignores generated/documentation directories by default', async () => { + test('builds deterministic v2 maps and ignores generated/documentation directories by default', async () => { const rootDir = await copyFixture('maps-basic'); const maps = await scanRepositoryMaps(rootDir); + expect(maps.repo.version).toBe(2); + expect(maps.commands.version).toBe(2); + expect(maps.tests.version).toBe(2); + expect(maps.risk.version).toBe(2); expect(maps.repo.files.map((file) => file.path)).toEqual([ '.github/workflows/ci.yml', 'package.json', @@ -48,15 +52,24 @@ describe('scanRepositoryMaps', () => { expect(maps.repo.files.map((file) => file.path)).not.toContain('node_modules/ignored.js'); expect(maps.repo.files.map((file) => file.path)).not.toContain('dist/ignored.js'); expect(maps.repo.files.map((file) => file.path)).not.toContain('kernel_obsidian_vault/Ignored.md'); + expect(maps.repo.monorepo).toEqual({ tool: null, workspaceFile: null }); + expect(maps.repo.packages).toEqual([]); expect(maps.commands.packageManager).toBe('pnpm'); + expect(maps.commands.sources).toEqual(['package.json']); expect(maps.commands.scripts).toEqual([ { name: 'build', command: 'pnpm build', script: 'tsc -p tsconfig.json' }, { name: 'lint', command: 'pnpm lint', script: 'eslint .' }, { name: 'release', command: 'pnpm release', script: 'npm publish' }, { name: 'test', command: 'pnpm test', script: 'vitest run' } ]); + expect(maps.commands.kernelCommands).toEqual([]); + expect(maps.commands.packageScripts).toEqual([]); + expect(maps.commands.taskRunnerTasks).toEqual([]); expect(maps.tests.testFiles).toEqual(['tests/index.test.ts']); expect(maps.tests.testCommands).toEqual([{ name: 'test', command: 'pnpm test', script: 'vitest run' }]); + expect(maps.tests.frameworks).toEqual(['vitest']); + expect(maps.tests.configFiles).toEqual([]); + expect(maps.tests.e2ePaths).toEqual([]); expect(maps.risk.highRiskPaths).toEqual([ { path: '.github/workflows/ci.yml', reason: 'CI workflow' } ]); @@ -68,6 +81,8 @@ describe('scanRepositoryMaps', () => { reason: 'package publishing' } ]); + expect(maps.risk.ownership).toEqual([]); + expect(maps.risk.configRiskPaths).toEqual([]); }); test('can explicitly include the documentation vault', async () => { @@ -77,6 +92,78 @@ describe('scanRepositoryMaps', () => { expect(maps.repo.files.map((file) => file.path)).toContain('kernel_obsidian_vault/Ignored.md'); }); + + test('detects makefile targets alongside package scripts', async () => { + const rootDir = await copyFixture('maps-makefile'); + + const maps = await scanRepositoryMaps(rootDir); + + expect(maps.commands.sources).toEqual(['makefile', 'package.json']); + expect(maps.commands.scripts.map((script) => script.name)).toEqual([ + 'make:build', + 'make:lint', + 'make:test', + 'test' + ]); + }); + + test('detects vitest config, frameworks, and e2e paths', async () => { + const rootDir = await copyFixture('maps-vitest'); + + const maps = await scanRepositoryMaps(rootDir); + + expect(maps.tests.frameworks).toEqual(['playwright', 'vitest']); + expect(maps.tests.configFiles).toEqual(['vitest.config.ts']); + expect(maps.tests.e2ePaths).toEqual(['e2e']); + expect(maps.tests.testFiles).toEqual(['e2e/login.spec.ts', 'src/__tests__/app.test.ts']); + expect(maps.tests.patterns.length).toBeGreaterThan(0); + }); + + test('merges CODEOWNERS ownership and configured risk paths', async () => { + const rootDir = await copyFixture('maps-codeowners'); + + const maps = await scanRepositoryMaps(rootDir); + + expect(maps.risk.configRiskPaths).toEqual([ + { pattern: 'src/auth/**', reason: 'configured high-risk path' } + ]); + expect(maps.risk.highRiskPaths).toEqual([{ path: 'src/auth/login.ts', reason: 'authentication' }]); + expect(maps.risk.ownership).toEqual([ + { + path: 'src/auth/login.ts', + owners: ['@platform-leads', '@security-team'], + source: 'codeowners' + } + ]); + }); + + test('detects pnpm workspace packages and per-package scripts', async () => { + const rootDir = await copyFixture('maps-monorepo'); + + const maps = await scanRepositoryMaps(rootDir); + + expect(maps.repo.monorepo).toEqual({ tool: 'pnpm', workspaceFile: 'pnpm-workspace.yaml' }); + expect(maps.repo.packages).toEqual([ + { name: '@maps-monorepo/app', path: 'packages/app' }, + { name: '@maps-monorepo/lib', path: 'packages/lib' } + ]); + expect(maps.repo.entrypoints).toEqual([{ path: 'dist/index.js', kind: 'main' }]); + expect(maps.commands.packageScripts).toEqual([ + { + package: '@maps-monorepo/app', + path: 'packages/app', + scripts: [ + { name: 'build', command: 'pnpm --filter @maps-monorepo/app build', script: 'tsc -p tsconfig.json' }, + { name: 'test', command: 'pnpm --filter @maps-monorepo/app test', script: 'vitest run' } + ] + }, + { + package: '@maps-monorepo/lib', + path: 'packages/lib', + scripts: [{ name: 'test', command: 'pnpm --filter @maps-monorepo/lib test', script: 'vitest run' }] + } + ]); + }); }); describe('generateKernelMaps', () => { @@ -93,16 +180,19 @@ describe('generateKernelMaps', () => { ]); expect(result.files.map((file) => file.action)).toEqual(['created', 'created', 'created', 'created']); await expect(readJson(join(rootDir, '.agent', 'maps', 'repo.json'))).resolves.toMatchObject({ - version: 1, + version: 2, summary: { fileCount: 6 } }); await expect(readJson(join(rootDir, '.agent', 'maps', 'commands.json'))).resolves.toMatchObject({ + version: 2, packageManager: 'pnpm' }); await expect(readJson(join(rootDir, '.agent', 'maps', 'tests.json'))).resolves.toMatchObject({ + version: 2, testFiles: ['tests/index.test.ts'] }); await expect(readJson(join(rootDir, '.agent', 'maps', 'risk.json'))).resolves.toMatchObject({ + version: 2, highRiskPaths: [{ path: '.github/workflows/ci.yml', reason: 'CI workflow' }] }); }); @@ -138,7 +228,20 @@ describe('generateKernelMaps', () => { expect(result.files.find((file) => file.relativePath === '.agent/maps/repo.json')?.action).toBe('updated'); await expect(readJson(join(rootDir, '.agent', 'maps', 'repo.json'))).resolves.toMatchObject({ - version: 1 + version: 2 + }); + }); + + test('writes only commands.json when --commands subset is selected', async () => { + const rootDir = await copyFixture('maps-basic'); + + const result = await generateKernelMaps(rootDir, { maps: ['commands'] }); + + expect(result.files.map((file) => file.relativePath)).toEqual(['.agent/maps/commands.json']); + await expect(readFile(join(rootDir, '.agent', 'maps', 'repo.json'), 'utf8')).rejects.toThrow(); + await expect(readJson(join(rootDir, '.agent', 'maps', 'commands.json'))).resolves.toMatchObject({ + version: 2, + packageManager: 'pnpm' }); }); }); diff --git a/tests/policy.test.ts b/tests/policy.test.ts new file mode 100644 index 0000000..c11f44e --- /dev/null +++ b/tests/policy.test.ts @@ -0,0 +1,137 @@ +import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { loadKernelConfig } from '../src/core/config.js'; +import { checkPolicy } from '../src/core/policy/check.js'; +import { classifyCommand, classifyPath } from '../src/core/policy/evaluate.js'; +import { inferVerificationLevel, loadTaskContext, resolveEscalation } from '../src/core/policy/escalation.js'; +import { loadPolicies } from '../src/core/policy/loader.js'; +import { checkCiPolicy } from '../src/core/policy/ci.js'; + +const tempDirs: string[] = []; + +async function copyFixture(name: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `kernel-policy-${name}-`)); + tempDirs.push(dir); + await cp(join(process.cwd(), 'tests', 'fixtures', name), dir, { recursive: true }); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('loadPolicies', () => { + test('loads policy-gate.yaml from fixture', async () => { + const rootDir = await copyFixture('policy-basic'); + const config = await loadKernelConfig(rootDir); + const { policyGate, sourceFiles } = await loadPolicies(rootDir, config); + + expect(sourceFiles).toEqual(['.agent/policies/policy-gate.yaml']); + expect(policyGate.commands.some((rule) => rule.match === 'npm publish')).toBe(true); + expect(policyGate.paths.some((rule) => rule.pattern === 'src/core/**')).toBe(true); + }); + + test('merges kernel.yaml destructive commands into defaults when no policy file exists', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'kernel-policy-empty-')); + tempDirs.push(rootDir); + await mkdir(join(rootDir, '.agent'), { recursive: true }); + await cp(join(process.cwd(), 'tests', 'fixtures', 'policy-basic', '.agent', 'kernel.yaml'), join(rootDir, '.agent', 'kernel.yaml')); + + const config = await loadKernelConfig(rootDir); + const { policyGate } = await loadPolicies(rootDir, config); + + expect(policyGate.commands.some((rule) => rule.match === 'npm publish')).toBe(true); + }); +}); + +describe('evaluate policy', () => { + test('classifies blocked commands', async () => { + const rootDir = await copyFixture('policy-basic'); + const { policyGate } = await loadPolicies(rootDir); + + const result = classifyCommand('pnpm publish --access public', policyGate); + + expect(result.policyClass).toBe('block'); + }); + + test('classifies review paths', async () => { + const rootDir = await copyFixture('policy-review-path'); + const { policyGate } = await loadPolicies(rootDir); + + const result = classifyPath('src/auth/login.ts', policyGate); + + expect(result.policyClass).toBe('review'); + expect(result.reason).toContain('authentication'); + }); +}); + +describe('checkPolicy', () => { + test('flags blocked commands from commands.json during passive scan', async () => { + const rootDir = await copyFixture('policy-blocked-command'); + + const result = await checkPolicy({ rootDir }); + + expect(result.status).toBe('fail'); + expect(result.violations).toContainEqual( + expect.objectContaining({ + code: 'policy_command_blocked', + path: 'npm release' + }) + ); + }); + + test('reports missing CI checks', async () => { + const rootDir = await copyFixture('policy-ci-missing'); + + const result = await checkPolicy({ rootDir, ci: true }); + + expect(result.violations).toContainEqual( + expect.objectContaining({ + code: 'missing_ci_check', + message: expect.stringContaining('pnpm lint') + }) + ); + }); + + test('reports insufficient verification for migration tasks', async () => { + const rootDir = await copyFixture('policy-escalation'); + + const result = await checkPolicy({ rootDir, task: 'current' }); + + expect(result.violations).toContainEqual( + expect.objectContaining({ + code: 'insufficient_verification_level', + path: '.agent/evidence/migration-task.md' + }) + ); + }); +}); + +describe('escalation', () => { + test('requires L5 for migration task type', async () => { + const rootDir = await copyFixture('policy-escalation'); + const { policyGate } = await loadPolicies(rootDir); + const task = await loadTaskContext(rootDir, 'current'); + + const requirement = resolveEscalation(policyGate, task, task.riskZones); + const actual = inferVerificationLevel(task); + + expect(requirement.minVerification).toBe('L5'); + expect(actual).toBe('L1'); + }); +}); + +describe('checkCiPolicy', () => { + test('detects github actions provider and missing checks', async () => { + const rootDir = await copyFixture('policy-ci-missing'); + const { policyGate } = await loadPolicies(rootDir); + + const result = await checkCiPolicy(rootDir, policyGate); + + expect(result.provider).toBe('github-actions'); + expect(result.missingChecks).toEqual(['pnpm lint']); + }); +}); diff --git a/tests/priority-adapters.test.ts b/tests/priority-adapters.test.ts index 3714dc5..9a4b8ff 100644 --- a/tests/priority-adapters.test.ts +++ b/tests/priority-adapters.test.ts @@ -36,12 +36,17 @@ describe('priority adapter registry', () => { 'claude', 'cursor', 'kiro', - 'github-copilot' + 'github-copilot', + 'gemini', + 'zed', + 'opencode', + 'windsurf', + 'junie' ]); }); test('throws for unsupported adapter targets', () => { - expect(() => getAdaptersForTarget('gemini')).toThrow('Unsupported adapter target: gemini'); + expect(() => getAdaptersForTarget('cline')).toThrow('Unsupported adapter target: cline'); }); }); @@ -56,10 +61,7 @@ describe('priority adapter rendering', () => { expect(claude.files.map((file) => file.relativePath)).toEqual([ 'CLAUDE.md', - '.claude/skills/kernel-core/SKILL.md', - '.claude/skills/kernel-review/SKILL.md', - '.claude/skills/kernel-debug/SKILL.md', - '.claude/skills/kernel-handoff/SKILL.md' + '.claude/skills/kernel-core/SKILL.md' ]); expect(cursor.files.map((file) => file.relativePath)).toEqual([ '.cursor/rules/kernel-core.mdc', @@ -130,13 +132,25 @@ describe('priority adapter rendering', () => { { "content": " - # Kernel Core + # kernel-core + + ## Purpose + + Coordinate task contracts, verification, evidence, and handoff discipline for non-trivial agent work. + + ## Trigger + + Use when a coding-agent task requires durable context, quality gates, or evidence-backed completion. + + ## Workflow + + 1. Read the current task contract. + 2. Select relevant quality gates. + 3. Record evidence before completion. - Always use Kernel's canonical source under \`.agent/\` before non-trivial changes in Multi Adapter Fixture. + ## Output - - Read \`.agent/kernel.yaml\`. - - Read or create \`.agent/state/current-task.md\`. - - Record evidence under \`.agent/evidence/\` before claiming completion. + Writes or updates \`.agent/state/current-task.md\` and \`.agent/evidence/\` artifacts when filesystem access is available. @@ -186,15 +200,23 @@ describe('compileAdapters', () => { const result = await compileAdapters(rootDir, getAdaptersForTarget('all'), { dryRun: true }); - expect(result.adapterNames).toEqual(['codex', 'claude', 'cursor', 'kiro', 'github-copilot']); + expect(result.adapterNames).toEqual([ + 'codex', + 'claude', + 'cursor', + 'kiro', + 'github-copilot', + 'gemini', + 'zed', + 'opencode', + 'windsurf', + 'junie' + ]); expect(result.files.map((file) => file.relativePath)).toEqual([ 'AGENTS.md', '.agents/skills/kernel-core/SKILL.md', 'CLAUDE.md', '.claude/skills/kernel-core/SKILL.md', - '.claude/skills/kernel-review/SKILL.md', - '.claude/skills/kernel-debug/SKILL.md', - '.claude/skills/kernel-handoff/SKILL.md', '.cursor/rules/kernel-core.mdc', '.cursor/rules/kernel-quality.mdc', '.cursor/rules/kernel-security.mdc', @@ -205,7 +227,14 @@ describe('compileAdapters', () => { '.github/copilot-instructions.md', '.github/instructions/testing.instructions.md', '.github/instructions/review.instructions.md', - '.github/skills/kernel-core/SKILL.md' + '.github/skills/kernel-core/SKILL.md', + 'GEMINI.md', + '.gemini/settings.json', + '.rules', + '.opencode/skills/kernel-core/SKILL.md', + '.windsurf/rules/kernel-core.md', + '.windsurf/workflows/kernel-review.md', + '.junie/AGENTS.md' ]); expect(result.files.every((file) => file.content.startsWith(GENERATED_FILE_HEADER))).toBe(true); }); diff --git a/tests/repo-intelligence.test.ts b/tests/repo-intelligence.test.ts new file mode 100644 index 0000000..8560e60 --- /dev/null +++ b/tests/repo-intelligence.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest'; + +import { parseCodeowners } from '../src/core/repo-intelligence/codeowners.js'; +import { matchGlob } from '../src/core/repo-intelligence/glob.js'; + +describe('matchGlob', () => { + test('matches single-segment wildcards', () => { + expect(matchGlob('src/*.ts', 'src/index.ts')).toBe(true); + expect(matchGlob('src/*.ts', 'src/lib/index.ts')).toBe(false); + }); + + test('matches recursive directory patterns', () => { + expect(matchGlob('src/core/**', 'src/core/maps.ts')).toBe(true); + expect(matchGlob('src/core/**', 'src/core')).toBe(true); + expect(matchGlob('src/core/**', 'src/other/maps.ts')).toBe(false); + }); +}); + +describe('parseCodeowners', () => { + test('parses owner tokens and ignores comments', () => { + const rules = parseCodeowners( + ['# team ownership', '/src/auth/ @security-team @platform-leads', ''].join('\n'), + '.github/CODEOWNERS' + ); + + expect(rules).toEqual([ + { + pattern: 'src/auth/', + owners: ['@platform-leads', '@security-team'], + source: '.github/CODEOWNERS' + } + ]); + }); +}); diff --git a/tests/tier2-adapters.test.ts b/tests/tier2-adapters.test.ts new file mode 100644 index 0000000..bdc5dfe --- /dev/null +++ b/tests/tier2-adapters.test.ts @@ -0,0 +1,55 @@ +import { cp, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { getAdapter } from '../src/adapters/index.js'; +import { compileAdapters } from '../src/core/adapter-compiler.js'; +import { GENERATED_FILE_HEADER } from '../src/core/manual-sections.js'; + +const tempDirs: string[] = []; + +async function copyFixture(name: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `kernel-tier2-adapters-${name}-`)); + tempDirs.push(dir); + await cp(join(process.cwd(), 'tests', 'fixtures', name), dir, { recursive: true }); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('tier 2 adapters', () => { + test('renders Zed outputs', async () => { + const rootDir = await copyFixture('compile-all-basic'); + const result = await compileAdapters(rootDir, [getAdapter('zed')], { dryRun: true }); + expect(result.files.map((file) => file.relativePath)).toEqual(['.rules']); + expect(result.files.every((file) => file.content.startsWith(GENERATED_FILE_HEADER))).toBe(true); + }); + + test('renders OpenCode mirrored skill outputs', async () => { + const rootDir = await copyFixture('compile-all-basic'); + const result = await compileAdapters(rootDir, [getAdapter('opencode')], { dryRun: true }); + expect(result.files.map((file) => file.relativePath)).toEqual([ + '.opencode/skills/kernel-core/SKILL.md', + '.agents/skills/kernel-core/SKILL.md' + ]); + }); + + test('renders Windsurf rule and workflow outputs', async () => { + const rootDir = await copyFixture('compile-all-basic'); + const result = await compileAdapters(rootDir, [getAdapter('windsurf')], { dryRun: true }); + expect(result.files.map((file) => file.relativePath)).toEqual([ + '.windsurf/rules/kernel-core.md', + '.windsurf/workflows/kernel-review.md' + ]); + }); + + test('renders Junie AGENTS output', async () => { + const rootDir = await copyFixture('compile-all-basic'); + const result = await compileAdapters(rootDir, [getAdapter('junie')], { dryRun: true }); + expect(result.files.map((file) => file.relativePath)).toEqual(['.junie/AGENTS.md']); + expect(result.files[0]?.content).toContain('## Junie workflow'); + }); +}); diff --git a/tests/validate.test.ts b/tests/validate.test.ts index d9a135b..3b2453c 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -55,6 +55,7 @@ describe('validateKernel', () => { expect(result.status).toBe('fail'); expect(result.issues.map((issue) => `${issue.code}:${issue.path}`)).toEqual([ + 'missing_policy_file:.agent/policies/policy-gate.yaml', 'missing_required_directory:.agent/adapters', 'missing_required_directory:.agent/contracts', 'missing_required_directory:.agent/evals', @@ -67,6 +68,25 @@ describe('validateKernel', () => { ]); }); + test('reports stale v1 map files as warnings', async () => { + const rootDir = await copyFixture('validate-valid'); + await writeFile( + join(rootDir, '.agent', 'maps', 'repo.json'), + `${JSON.stringify({ version: 1 }, null, 2)}\n`, + 'utf8' + ); + + const result = await validateKernel(rootDir); + + expect(result.status).toBe('warn'); + expect(result.issues).toContainEqual({ + code: 'stale_map_version', + severity: 'warning', + path: '.agent/maps/repo.json', + message: 'Map file is version 1; regenerate with `kernel map --force` to upgrade to version 2.' + }); + }); + test('reports deterministic warnings for incomplete generated artifacts', async () => { const rootDir = await copyFixture('validate-warnings');