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:
+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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user