fix(P01): migrate audit trail to git-native and replace audit_file with commit_hash

---ci---
project: ci
phase: 1
milestone: v0.8
status: in_progress
decisions:
  - id: D-024
    decision: Audit trail reads from git log instead of .ciagent/audit/*.json
    rationale: Git-native context means audit data should come from commit history, not files
    confidence: 0.88
  - id: D-025
    decision: Replace audit_file with commit_hash in Escalation type
    rationale: Escalations are committed to git; reference by hash instead of deprecated file path
    confidence: 0.90
requirements:
  covered: [FIX-04, FIX-05]
---/ci---

FIX-04: audit.ts logDecision/logEscalation now emit deprecation warnings
and are no-ops (decisions/escalations live in ---ci--- blocks). readAudit()
and getAuditSummary() parse git log for ---ci--- blocks instead of reading
.ciagent/audit/*.json files. ArtifactManager no longer creates audit dir.

FIX-05: Escalation type replaces audit_file: string with commit_hash: string.
All consumers updated (escalation.ts, ollama-base.ts, opencode.ts).
Audit tests rewritten for git-native approach.
This commit is contained in:
Jon Chery
2026-05-29 20:02:07 +00:00
parent 5fb285cf46
commit 04c4489e70
7 changed files with 222 additions and 132 deletions
+1 -1
View File
@@ -328,7 +328,7 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
options: Array.isArray(e.options) ? e.options : [],
default_option_id: String(e.default_option_id || ""),
resolution: (e.resolution as Escalation["resolution"]) || "pending",
audit_file: String(e.audit_file || ""),
commit_hash: String(e.commit_hash || ""),
}));
}
+1 -1
View File
@@ -20,7 +20,7 @@ describe("ArtifactManager", () => {
it("creates .ciagent directory structure", () => {
manager.ensureStructure();
expect(fs.existsSync(path.join(tempDir, ".ciagent"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, ".ciagent", "audit"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, ".ciagent", "phases"))).toBe(true);
});
it("is idempotent", () => {
-1
View File
@@ -55,7 +55,6 @@ export class ArtifactManager {
ensureStructure(): void {
ensureDir(this.ciDir);
ensureDir(path.join(this.ciDir, "phases"));
ensureDir(path.join(this.ciDir, "audit"));
}
isInitialized(): boolean {
+128 -64
View File
@@ -1,16 +1,23 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { execSync } from "node:child_process";
import { logDecision, logEscalation, readAudit, getAuditSummary } from "../core/audit.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
describe("Audit", () => {
describe("Audit (git-native)", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-audit-test-"));
fs.mkdirSync(path.join(tempDir, ".ciagent", "audit"), { recursive: true });
fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
execSync("git init", { cwd: tempDir, stdio: "pipe" });
execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: "pipe" });
execSync('git config user.name "Test"', { cwd: tempDir, stdio: "pipe" });
const placeholder = path.join(tempDir, "README.md");
fs.writeFileSync(placeholder, "# test\n");
execSync("git add -A && git commit -m 'initial'", { cwd: tempDir, stdio: "pipe" });
});
afterEach(() => {
@@ -40,12 +47,48 @@ describe("Audit", () => {
],
default_option_id: "A",
resolution: "pending",
audit_file: ".ciagent/audit/test.json",
commit_hash: "",
};
describe("logDecision", () => {
it("logs a decision to the audit trail", () => {
describe("deprecated log functions", () => {
it("logDecision is a no-op that warns", () => {
logDecision(tempDir, 1, sampleDecision);
const audit = readAudit(tempDir);
expect(audit).toHaveLength(0);
});
it("logEscalation is a no-op that warns", () => {
logEscalation(tempDir, 1, sampleEscalation);
const audit = readAudit(tempDir);
expect(audit).toHaveLength(0);
});
});
describe("readAudit from git log", () => {
it("returns empty array when no ci blocks exist", () => {
const audit = readAudit(tempDir);
expect(audit).toEqual([]);
});
it("reads decisions from ---ci--- blocks in git log", () => {
const ciBlock = `docs(P01): test commit
---ci---
project: ci
phase: 1
milestone: v0.8
status: in_progress
decisions:
- id: D-001
decision: Use PostgreSQL
rationale: ACID compliance needed
confidence: 0.92
---/ci---`;
execSync(`git add -A && git commit -m "${ciBlock.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: tempDir,
stdio: "pipe",
});
const audit = readAudit(tempDir);
expect(audit).toHaveLength(1);
expect(audit[0].phase).toBe(1);
@@ -53,47 +96,35 @@ describe("Audit", () => {
expect(audit[0].decisions[0].id).toBe("D-001");
});
it("appends multiple decisions to same phase file", () => {
logDecision(tempDir, 1, { ...sampleDecision, id: "D-001" });
logDecision(tempDir, 1, { ...sampleDecision, id: "D-002" });
const audit = readAudit(tempDir);
expect(audit[0].decisions).toHaveLength(2);
});
it("separates decisions into different phase files", () => {
logDecision(tempDir, 1, sampleDecision);
logDecision(tempDir, 2, { ...sampleDecision, id: "D-002" });
const audit = readAudit(tempDir);
expect(audit).toHaveLength(2);
});
});
describe("logEscalation", () => {
it("logs an escalation to the audit trail", () => {
logEscalation(tempDir, 1, sampleEscalation);
const audit = readAudit(tempDir);
expect(audit).toHaveLength(1);
expect(audit[0].escalations).toHaveLength(1);
});
it("can mix decisions and escalations in same phase", () => {
logDecision(tempDir, 1, sampleDecision);
logEscalation(tempDir, 1, sampleEscalation);
const audit = readAudit(tempDir);
expect(audit[0].decisions).toHaveLength(1);
expect(audit[0].escalations).toHaveLength(1);
});
});
describe("readAudit", () => {
it("returns empty array when no audit files exist", () => {
const audit = readAudit(tempDir);
expect(audit).toEqual([]);
});
it("filters by phase number", () => {
logDecision(tempDir, 1, sampleDecision);
logDecision(tempDir, 2, { ...sampleDecision, id: "D-002" });
const ciBlock1 = `docs(P01): phase 1 commit
---ci---
project: ci
phase: 1
milestone: v0.8
status: complete
decisions:
- id: D-001
decision: Phase 1 decision
rationale: reason
confidence: 0.90
---/ci---`;
const ciBlock2 = `docs(P02): phase 2 commit
---ci---
project: ci
phase: 2
milestone: v0.8
status: in_progress
decisions:
- id: D-002
decision: Phase 2 decision
rationale: reason
confidence: 0.80
---/ci---`;
execSync(`git commit --allow-empty -m "${ciBlock1.replace(/"/g, '\\"')}"`, { cwd: tempDir, stdio: "pipe" });
execSync(`git commit --allow-empty -m "${ciBlock2.replace(/"/g, '\\"')}"`, { cwd: tempDir, stdio: "pipe" });
const phase1 = readAudit(tempDir, 1);
expect(phase1).toHaveLength(1);
@@ -101,29 +132,62 @@ describe("Audit", () => {
});
});
describe("getAuditSummary", () => {
it("returns summary with counts", () => {
logDecision(tempDir, 1, { ...sampleDecision, confidence: 0.95 });
logDecision(tempDir, 1, { ...sampleDecision, id: "D-002", confidence: 0.7 });
logDecision(tempDir, 2, { ...sampleDecision, id: "D-003", confidence: 0.4 });
logEscalation(tempDir, 1, sampleEscalation);
const summary = getAuditSummary(tempDir);
expect(summary.total_decisions).toBe(3);
expect(summary.total_escalations).toBe(1);
expect(summary.phases).toContain(1);
expect(summary.phases).toContain(2);
expect(summary.decisions_by_confidence.high).toBe(1);
expect(summary.decisions_by_confidence.medium).toBe(1);
expect(summary.decisions_by_confidence.low).toBe(1);
expect(summary.escalations_by_type.irreversible_action).toBe(1);
});
it("returns zeros for empty audit", () => {
describe("getAuditSummary from git log", () => {
it("returns zeros for empty git log with no ci blocks", () => {
const summary = getAuditSummary(tempDir);
expect(summary.total_decisions).toBe(0);
expect(summary.total_escalations).toBe(0);
expect(summary.phases).toHaveLength(0);
});
it("returns summary with decision counts and confidence breakdown", () => {
const ciBlock = `docs(P01): multi-decision commit
---ci---
project: ci
phase: 1
milestone: v0.8
status: complete
decisions:
- id: D-001
decision: High confidence decision
rationale: reason
confidence: 0.95
- id: D-002
decision: Medium confidence decision
rationale: reason
confidence: 0.70
- id: D-003
decision: Low confidence decision
rationale: reason
confidence: 0.40
---/ci---`;
execSync(`git commit --allow-empty -m "${ciBlock.replace(/"/g, '\\"')}"`, { cwd: tempDir, stdio: "pipe" });
const summary = getAuditSummary(tempDir);
expect(summary.total_decisions).toBe(3);
expect(summary.decisions_by_confidence.high).toBe(1);
expect(summary.decisions_by_confidence.medium).toBe(1);
expect(summary.decisions_by_confidence.low).toBe(1);
expect(summary.phases).toContain(1);
});
it("reads escalations from ci blocks", () => {
const ciBlock = `escalation(P01): test escalation
---ci---
project: ci
phase: 1
milestone: v0.8
escalations:
- type: irreversible_action
description: Deploy to production
---/ci---`;
execSync(`git commit --allow-empty -m "${ciBlock.replace(/"/g, '\\"')}"`, { cwd: tempDir, stdio: "pipe" });
const summary = getAuditSummary(tempDir);
expect(summary.total_escalations).toBe(1);
expect(summary.escalations_by_type.irreversible_action).toBe(1);
});
});
});
+90 -63
View File
@@ -1,7 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { execSync } from "node:child_process";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
import { confidenceToLevel } from "../types/decisions.js";
export interface AuditEntry {
phase: number;
@@ -9,41 +9,15 @@ export interface AuditEntry {
escalations: Escalation[];
}
const AUDIT_DIR = "audit";
function getAuditDir(projectPath: string): string {
return path.join(projectPath, ".ciagent", AUDIT_DIR);
}
function getAuditFilePath(projectPath: string, phase: number): string {
const date = new Date().toISOString().split("T")[0];
return path.join(getAuditDir(projectPath), `${date}-phase${phase}-decisions.json`);
}
function ensureAuditDir(projectPath: string): void {
const dir = getAuditDir(projectPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
export function logDecision(
projectPath: string,
phase: number,
decision: Decision
): void {
ensureAuditDir(projectPath);
const filePath = getAuditFilePath(projectPath, phase);
let entry: AuditEntry;
if (fs.existsSync(filePath)) {
entry = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} else {
entry = { phase, decisions: [], escalations: [] };
}
entry.decisions.push(decision);
fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), "utf-8");
console.warn(
`[DEPRECATED] logDecision() is a no-op. Decisions are now committed to git via ---ci--- blocks. ` +
`Read audit data with readAudit() or getAuditSummary() which derive from git log.`
);
}
export function logEscalation(
@@ -51,41 +25,20 @@ export function logEscalation(
phase: number,
escalation: Escalation
): void {
ensureAuditDir(projectPath);
const filePath = getAuditFilePath(projectPath, phase);
let entry: AuditEntry;
if (fs.existsSync(filePath)) {
entry = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} else {
entry = { phase, decisions: [], escalations: [] };
}
entry.escalations.push(escalation);
fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), "utf-8");
console.warn(
`[DEPRECATED] logEscalation() is a no-op. Escalations are now committed to git via ---ci--- blocks. ` +
`Read audit data with readAudit() or getAuditSummary() which derive from git log.`
);
}
export function readAudit(
projectPath: string,
phase?: number
): AuditEntry[] {
const auditDir = getAuditDir(projectPath);
if (!fs.existsSync(auditDir)) return [];
const files = fs
.readdirSync(auditDir)
.filter((f) => f.endsWith("-decisions.json"))
.sort();
const entries: AuditEntry[] = [];
for (const file of files) {
const content = fs.readFileSync(path.join(auditDir, file), "utf-8");
const entry: AuditEntry = JSON.parse(content);
if (phase === undefined || entry.phase === phase) {
entries.push(entry);
}
const entries = readAuditFromGit(projectPath);
if (phase !== undefined) {
return entries.filter((e) => e.phase === phase);
}
return entries;
}
@@ -96,7 +49,7 @@ export function getAuditSummary(projectPath: string): {
decisions_by_confidence: Record<string, number>;
escalations_by_type: Record<string, number>;
} {
const entries = readAudit(projectPath);
const entries = readAuditFromGit(projectPath);
let total_decisions = 0;
let total_escalations = 0;
const phases = new Set<number>();
@@ -113,8 +66,7 @@ export function getAuditSummary(projectPath: string): {
total_escalations += entry.escalations.length;
for (const d of entry.decisions) {
const level =
d.confidence > 0.85 ? "high" : d.confidence >= 0.6 ? "medium" : "low";
const level = confidenceToLevel(d.confidence);
decisions_by_confidence[level]++;
}
@@ -131,4 +83,79 @@ export function getAuditSummary(projectPath: string): {
decisions_by_confidence,
escalations_by_type,
};
}
function readAuditFromGit(projectPath: string): AuditEntry[] {
try {
const raw = execSync(
`git log --all --max-count=200 --format="%B%x01"`,
{ cwd: projectPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 10000 }
);
const phaseMap = new Map<number, AuditEntry>();
const entries = raw.split("\x01").filter(Boolean);
for (const entry of entries) {
const ciBlockMatch = entry.match(/---ci---[\s\S]*?---\/ci---/);
if (!ciBlockMatch) continue;
const phaseMatch = ciBlockMatch[0].match(/phase:\s*(\d+)/);
if (!phaseMatch) continue;
const phase = parseInt(phaseMatch[1]);
if (!phaseMap.has(phase)) {
phaseMap.set(phase, { phase, decisions: [], escalations: [] });
}
const auditEntry = phaseMap.get(phase)!;
const decisionsMatch = ciBlockMatch[0].match(/decisions:\s*\n([\s\S]*?)(?=\n[a-z]|---\/ci---)/);
if (decisionsMatch) {
const idMatches = [...decisionsMatch[1].matchAll(/id:\s*(D-\d+)/g)];
const decMatches = [...decisionsMatch[1].matchAll(/decision:\s*(.+)/g)];
const ratMatches = [...decisionsMatch[1].matchAll(/rationale:\s*(.+)/g)];
const confMatches = [...decisionsMatch[1].matchAll(/confidence:\s*([0-9.]+)/g)];
const catMatches = [...decisionsMatch[1].matchAll(/category:\s*(.+)/g)];
for (let i = 0; i < idMatches.length; i++) {
auditEntry.decisions.push({
id: idMatches[i]?.[1] || "D-000",
decision: decMatches[i]?.[1]?.trim() || "",
rationale: ratMatches[i]?.[1]?.trim() || "",
confidence: parseFloat(confMatches[i]?.[1] || "0.5"),
category: (catMatches[i]?.[1]?.trim() as Decision["category"]) || "general",
timestamp: new Date().toISOString(),
alternatives_considered: [],
human_override: null,
});
}
}
const escMatch = ciBlockMatch[0].match(/escalations:\s*\n([\s\S]*?)(?=\n[a-z]|---\/ci---)/);
if (escMatch) {
const escEntries = escMatch[1].split(/-\s*/).filter(Boolean);
for (const escLine of escEntries) {
const typeMatch = escLine.match(/type:\s*(\S+)/);
const descMatch = escLine.match(/description:\s*(.+)/);
if (typeMatch) {
auditEntry.escalations.push({
id: "E-000",
timestamp: new Date().toISOString(),
type: typeMatch[1] as Escalation["type"],
phase: String(phase),
description: descMatch?.[1]?.trim() || "",
context: "",
options: [],
default_option_id: "",
resolution: "pending",
commit_hash: "",
});
}
}
}
}
return [...phaseMap.values()];
} catch {
return [];
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ export class EscalationProtocol {
options: input.options,
default_option_id: input.default_option_id,
resolution: "pending",
audit_file: `.ciagent/audit/deprecated`,
commit_hash: "",
};
this.pendingEscalations.set(id, escalation);
+1 -1
View File
@@ -33,7 +33,7 @@ export interface Escalation {
resolution: EscalationResolution;
resolved_at?: string;
resolution_detail?: string;
audit_file: string;
commit_hash: string;
}
export interface EscalationResult {