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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,29 @@ jobs:

- name: Lint TypeScript
run: npx tsc --noEmit

cockpit-crvs:
name: Cockpit tsc + CRVS evidence gates
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install root dependencies
run: npm ci

- name: Install cockpit dependencies
run: npm ci
working-directory: cockpit

- name: Cockpit TypeScript (strict)
run: npx tsc --noEmit
working-directory: cockpit

- name: CRVS contract + anti-fabrication tests
run: node --require tsx --test tests/crvs.test.ts
44 changes: 44 additions & 0 deletions .github/workflows/deploy-cockpit-demo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Deploy Cockpit Demo
on:
push:
branches:
- main
- demo/cockpit-sovereign-hud
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: cockpit-pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: cockpit/package-lock.json
- name: Install Cockpit dependencies
run: npm ci --prefix cockpit
- name: Build Cockpit
run: npm run build --prefix cockpit
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: cockpit/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ venv/

# Nova runtime artifacts
.nova/sessions/
.nova/ledger.wal.jsonl
agent.db
*.sqlite

Expand Down
17 changes: 13 additions & 4 deletions agent/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import { AgentRuntime, governance, continuity } from "./index";
import { getContext } from "./runtime/workspace";
import { invariants } from "../config/nova.config";
import { refineCode } from "./core/agentLoop";
import { GenerationBlockedError } from "./core/agent";
import { registerSpineObserve } from "./governance/observe";

async function bootstrapGovernance(): Promise<void> {
for (const inv of invariants) {
await governance.requireInvariant(inv);
}
// When NOVA_OBSERVE=1 or NOVA_SPINE_URL / NOVA_SPINE_INGEST_URL is set, forward receipts to spine
registerSpineObserve();
}

/** Spinner with elapsed time tracking. */
Expand Down Expand Up @@ -118,11 +122,16 @@ program
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("\n✖ BLOCKED:", message);
const receipts = await governance.listReceipts();
const last = receipts[receipts.length - 1];
if (last) {
if (err instanceof GenerationBlockedError && err.receipts.length > 0) {
console.log("\nReceipts:");
console.log(JSON.stringify([last], null, 2));
console.log(JSON.stringify(err.receipts, null, 2));
} else {
const receipts = await governance.listReceipts();
const last = receipts[receipts.length - 1];
if (last) {
console.log("\nReceipts:");
console.log(JSON.stringify([last], null, 2));
}
}
process.exit(1);
}
Expand Down
36 changes: 33 additions & 3 deletions agent/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ApplyPatchInput,
ApplyPatchResult,
} from "../types/actions";
import type { Crk1Provenance, GovernanceReceipt } from "../types/receipts";
import { validateAction } from "../governance/validator";
import { recordReceipt } from "../governance/receipts";
import { getInvariants } from "../governance/invariants";
Expand All @@ -17,11 +18,33 @@ import { updateContinuity } from "../continuity/substrate";
import { plan as createPlan } from "./planner";
import { applyDiff, openFile, getContext } from "../runtime/workspace";
import { governedPredict, GovernedRefusalError } from "../../src/runtime/governedPredict";
import type { GovernedResult } from "../../src/runtime/types";

const DEFAULT_OPERATOR_ID = process.env.NOVA_OPERATOR_ID ?? "agent-sdk";
const DEFAULT_MODE = (process.env.NOVA_GOVERNED_MODE ?? "predict") as "predict" | "observe" | "correct";
const DEFAULT_INVARIANT_SET = process.env.NOVA_INVARIANT_SET ?? "K0-K12-v1";

/** Thrown when generate/refactor is blocked after a receipt was written — carries receipts for HTTP/CLI. */
export class GenerationBlockedError extends Error {
readonly receipts: GovernanceReceipt[];

constructor(message: string, receipts: GovernanceReceipt[]) {
super(message);
this.name = "GenerationBlockedError";
this.receipts = receipts;
}
}

function crk1FromGoverned(result: GovernedResult): Crk1Provenance {
return {
receipt: result.receipt,
lineage: result.lineage,
ce1: result.ce1,
crr1: result.crr1,
clg1: result.clg1,
};
}

async function resolveFileContext(context?: GenerateCodeInput["context"]): Promise<{
files: Array<{ path: string; content: string }>;
language?: string;
Expand Down Expand Up @@ -58,7 +81,7 @@ export async function generateCode(input: GenerateCodeInput): Promise<GenerateCo
if (!validation.ok) {
const receipt = await recordReceipt(action, [], { blocked: true, blockReason: validation.reason });
emitReceipt(receipt);
throw new Error(validation.reason ?? "Generation blocked by invariant");
throw new GenerationBlockedError(validation.reason ?? "Generation blocked by invariant", [receipt]);
}

const resolvedCtx = await resolveFileContext(input.context);
Expand All @@ -74,7 +97,10 @@ export async function generateCode(input: GenerateCodeInput): Promise<GenerateCo
});

const invIds = getInvariants().map((i) => i.id);
const receipt = await recordReceipt(action, invIds, { assuranceLevel: governed.receipt.invariants_passed ? "A1" : "A0" });
const receipt = await recordReceipt(action, invIds, {
assuranceLevel: governed.receipt.invariants_passed ? "A1" : "A0",
crk1: crk1FromGoverned(governed),
});
await updateContinuity(action);
emitReceipt(receipt);
return { code: governed.output, receipts: [receipt] };
Expand All @@ -84,9 +110,13 @@ export async function generateCode(input: GenerateCodeInput): Promise<GenerateCo
const receipt = await recordReceipt(action, err.result.receipt.violation_ids ?? [], {
blocked: true,
blockReason: `CRK-1 violations: ${(err.result.receipt.violation_ids ?? []).join(", ")}`,
crk1: crk1FromGoverned(err.result),
});
emitReceipt(receipt);
throw new Error(`Generation blocked by invariant: ${(err.result.receipt.violation_ids ?? []).join(", ")}`);
throw new GenerationBlockedError(
`Generation blocked by invariant: ${(err.result.receipt.violation_ids ?? []).join(", ")}`,
[receipt]
);
}
throw err;
}
Expand Down
11 changes: 10 additions & 1 deletion agent/governance/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
export { recordReceipt, getReceipt, listReceipts } from "./receipts";
export type { RecordReceiptOptions } from "./receipts";
export { onReceiptRecorded } from "./receipt-hooks";
export { registerSpineObserve, isObserveEnabled } from "./observe";
export { requireInvariant, getInvariants } from "./invariants";
export { validateAction, trace } from "./validator";
export { getLedger, getLedgerTailHash, appendToLedger } from "./ledger";
export {
getLedger,
getLedgerTailHash,
appendToLedger,
clearLedger,
getLedgerWalPath,
readLedgerWal,
} from "./ledger";
export { kernelStatus, emitKernelHeartbeat } from "./kernelStatus";
export type { KernelStatus, KernelHeartbeat } from "./kernelStatus";

Expand Down
73 changes: 73 additions & 0 deletions agent/governance/ledger.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,94 @@
/**
* Agent governance ledger — in-memory hash chain + durable JSONL WAL.
* Pattern: sovereign-x/storage.ts (append-only WAL) + skillzmcgee evidenceLedger
* / ai-factory factory_ledger.jsonl (one JSON object per line).
*/
import * as fs from "fs";
import * as path from "path";
import type { GovernanceReceipt } from "../types/receipts";
import type { Hash } from "../../inas/spec/core";

const ledger: GovernanceReceipt[] = [];
let loaded = false;

function walPath(): string {
return process.env.NOVA_LEDGER_WAL || path.join(process.cwd(), ".nova", "ledger.wal.jsonl");
}

function ensureWalDir(): void {
const dir = path.dirname(walPath());
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}

function ensureLoaded(): void {
if (loaded) return;
loaded = true;
const file = walPath();
if (!fs.existsSync(file)) return;
try {
const content = fs.readFileSync(file, "utf-8").trim();
if (!content) return;
for (const line of content.split("\n").filter(Boolean)) {
ledger.push(JSON.parse(line) as GovernanceReceipt);
}
} catch {
// Corrupt WAL must not break the constitutional path — start fresh in-memory
}
}

function appendWal(receipt: GovernanceReceipt): void {
try {
ensureWalDir();
fs.appendFileSync(walPath(), `${JSON.stringify(receipt)}\n`, "utf-8");
} catch {
// Durability best-effort; in-memory chain remains authoritative for this process
}
}

function truncateWal(): void {
try {
ensureWalDir();
fs.writeFileSync(walPath(), "", "utf-8");
} catch {
// ignore
}
}

export function appendToLedger(receipt: GovernanceReceipt): void {
ensureLoaded();
ledger.push(receipt);
appendWal(receipt);
}

export function getLedger(): readonly GovernanceReceipt[] {
ensureLoaded();
return ledger;
}

export function getLedgerTailHash(): Hash {
ensureLoaded();
if (ledger.length === 0) return "genesis" as Hash;
return ledger[ledger.length - 1].hash;
}

export function clearLedger(): void {
ledger.length = 0;
loaded = true;
truncateWal();
}

/** Absolute path of the durable WAL file (for tests / ops). */
export function getLedgerWalPath(): string {
return walPath();
}

/** Replay WAL from disk without mutating in-memory state (read-only inspection). */
export function readLedgerWal(): GovernanceReceipt[] {
const file = walPath();
if (!fs.existsSync(file)) return [];
const content = fs.readFileSync(file, "utf-8").trim();
if (!content) return [];
return content.split("\n").filter(Boolean).map((line) => JSON.parse(line) as GovernanceReceipt);
}
56 changes: 56 additions & 0 deletions agent/governance/observe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* CLI / process observe bridge — when NOVA_OBSERVE=1 or a spine URL env is set,
* forward recorded agent receipts to the live Nova spine ingest endpoint.
*/
import { onReceiptRecorded } from "./receipt-hooks";
import type { GovernanceReceipt } from "../types/receipts";

let registered = false;

function spineBaseUrl(): string | null {
const explicit =
process.env.NOVA_SPINE_INGEST_URL ||
process.env.NOVA_SPINE_URL ||
process.env.NOVA_API_URL;
if (explicit) return explicit.replace(/\/$/, "");

const observe = process.env.NOVA_OBSERVE;
if (observe === "1" || observe === "true") {
const port = process.env.NOVA_API_PORT || "3737";
return `http://127.0.0.1:${port}`;
}
return null;
}

function ingestUrl(base: string): string {
if (base.includes("/api/receipts")) return base;
return `${base}/api/receipts/ingest`;
}

/**
* Register a one-shot receipt hook that POSTs to spine ingest.
* Safe to call multiple times — only registers once per process.
*/
export function registerSpineObserve(): (() => void) | undefined {
if (registered) return undefined;
const base = spineBaseUrl();
if (!base) return undefined;

registered = true;
const url = ingestUrl(base);

return onReceiptRecorded((receipt: GovernanceReceipt) => {
void fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(receipt),
}).catch(() => {
// Observe must never break generation / receipt recording
});
});
}

/** True when observe env would register a spine hook. */
export function isObserveEnabled(): boolean {
return spineBaseUrl() !== null;
}
Loading
Loading