From 04c4489e70dc6bbf984ccd0d4e8d009847408ba7 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Fri, 29 May 2026 20:02:07 +0000 Subject: [PATCH] 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. --- src/backends/ollama-base.ts | 2 +- src/core/artifacts.test.ts | 2 +- src/core/artifacts.ts | 1 - src/core/audit.test.ts | 192 ++++++++++++++++++++++++------------ src/core/audit.ts | 153 ++++++++++++++++------------ src/core/escalation.ts | 2 +- src/types/escalation.ts | 2 +- 7 files changed, 222 insertions(+), 132 deletions(-) diff --git a/src/backends/ollama-base.ts b/src/backends/ollama-base.ts index 4d8eb29..6b37745 100644 --- a/src/backends/ollama-base.ts +++ b/src/backends/ollama-base.ts @@ -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 || ""), })); } diff --git a/src/core/artifacts.test.ts b/src/core/artifacts.test.ts index f5e8075..61123d2 100644 --- a/src/core/artifacts.test.ts +++ b/src/core/artifacts.test.ts @@ -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", () => { diff --git a/src/core/artifacts.ts b/src/core/artifacts.ts index 47f0672..6b2c4f6 100644 --- a/src/core/artifacts.ts +++ b/src/core/artifacts.ts @@ -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 { diff --git a/src/core/audit.test.ts b/src/core/audit.test.ts index b97bcb7..83a4dc7 100644 --- a/src/core/audit.test.ts +++ b/src/core/audit.test.ts @@ -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); + }); }); }); \ No newline at end of file diff --git a/src/core/audit.ts b/src/core/audit.ts index 5050abc..06b9b29 100644 --- a/src/core/audit.ts +++ b/src/core/audit.ts @@ -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; escalations_by_type: Record; } { - const entries = readAudit(projectPath); + const entries = readAuditFromGit(projectPath); let total_decisions = 0; let total_escalations = 0; const phases = new Set(); @@ -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(); + 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 []; + } } \ No newline at end of file diff --git a/src/core/escalation.ts b/src/core/escalation.ts index dccc4ce..4855348 100644 --- a/src/core/escalation.ts +++ b/src/core/escalation.ts @@ -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); diff --git a/src/types/escalation.ts b/src/types/escalation.ts index 3a9b0b3..dac2295 100644 --- a/src/types/escalation.ts +++ b/src/types/escalation.ts @@ -33,7 +33,7 @@ export interface Escalation { resolution: EscalationResolution; resolved_at?: string; resolution_detail?: string; - audit_file: string; + commit_hash: string; } export interface EscalationResult {