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:
@@ -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 || ""),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface Escalation {
|
||||
resolution: EscalationResolution;
|
||||
resolved_at?: string;
|
||||
resolution_detail?: string;
|
||||
audit_file: string;
|
||||
commit_hash: string;
|
||||
}
|
||||
|
||||
export interface EscalationResult {
|
||||
|
||||
Reference in New Issue
Block a user