@blundergoat/goat-flow
Version:
AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.
279 lines • 12.3 kB
JavaScript
/**
* Generate project-local commit guidance from recent git history.
* The detector prefers existing project convention over goat-flow defaults so installed guidance
* helps future agents write useful commit subjects instead of generic "improve/clarify" summaries.
*/
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
/**
* Canonical project-relative location for generated commit guidance.
*
* A docs path - not a bespoke .github file - because IDEs only auto-read .github/copilot-instructions.md;
* the instruction file points here, so one doc serves humans and every agent. See ADR-031.
*/
const GIT_COMMIT_INSTRUCTIONS_PATH = "docs/coding-standards/git-commit.md";
const CONVENTIONAL_SUBJECT_RE = /^(?<type>[a-z][a-z0-9-]*)(?:\([^)]+\))?!?: .+/u;
const TICKET_SUBJECT_RE = /^(?:\[(?<bracketKey>[A-Z][A-Z0-9]+-\d+)\]|(?<plainKey>[A-Z][A-Z0-9]+-\d+))(?::|\s)\s*.+/u;
/** Build the fallback detection used when git history cannot support a project-specific rule. */
function emptyDetection(gitAvailable) {
return {
status: "insufficient-history",
total: 0,
counts: { conventional: 0, ticketPrefixed: 0, freeForm: 0 },
conventionalTypes: [],
subjectLengthP95: null,
bodiesUsed: false,
coAuthoredBy: false,
signedOffBy: false,
ticketPrefixPattern: null,
exampleSubject: null,
gitAvailable,
};
}
/** Classify one commit subject before aggregate style detection chooses a dominant convention. */
function classifySubject(subject) {
if (CONVENTIONAL_SUBJECT_RE.test(subject))
return "conventional";
if (TICKET_SUBJECT_RE.test(subject))
return "ticket-prefixed";
return "free-form";
}
/** Parse `git log --format=%B%x1e` output while preserving commit bodies for trailer detection. */
function parseMessages(output) {
return output
.split("\x1e")
.map((raw) => raw.trim())
.filter((raw) => raw.length > 0)
.map((raw) => {
const [subjectLine = "", ...bodyLines] = raw.split(/\r?\n/u);
const subject = subjectLine.trim();
return {
subject,
body: bodyLines.join("\n").trim(),
kind: classifySubject(subject),
};
})
.filter((message) => message.subject.length > 0);
}
/** Stable p95 subject-length helper; one extreme commit must not set generated guidance. */
function percentile95(values) {
if (values.length === 0)
return null;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
return sorted[index] ?? null;
}
/** Mutates a local frequency map, then returns deterministic conventional types for generated examples. */
function conventionalTypes(messages) {
const counts = new Map();
for (const message of messages) {
const match = CONVENTIONAL_SUBJECT_RE.exec(message.subject);
const type = match?.groups?.type;
if (!type)
continue;
counts.set(type, (counts.get(type) ?? 0) + 1);
}
return [...counts.entries()]
.sort(([a, aCount], [b, bCount]) => bCount - aCount || a.localeCompare(b))
.map(([type]) => type);
}
/** Reads the ticket project key without baking one tracker name into the regex. */
function ticketPrefix(subject) {
const match = TICKET_SUBJECT_RE.exec(subject);
const ticket = match?.groups?.bracketKey ?? match?.groups?.plainKey;
if (!ticket)
return null;
return ticket.split("-")[0] ?? null;
}
function collectTicketPrefixes(messages) {
const prefixes = new Map();
for (const message of messages) {
const prefix = ticketPrefix(message.subject);
if (!prefix)
continue;
prefixes.set(prefix, (prefixes.get(prefix) ?? 0) + 1);
}
return prefixes;
}
/** Infer a narrow ticket prefix only when history consistently uses one project key. */
function ticketPrefixPattern(messages) {
const prefixes = collectTicketPrefixes(messages);
if (prefixes.size === 0)
return null;
if (prefixes.size === 1) {
const prefix = prefixes.keys().next().value;
if (typeof prefix !== "string")
return null;
return `^${prefix}-\\d+`;
}
return "^[A-Z][A-Z0-9]+-\\d+";
}
function dominantStatus(counts, total) {
const threshold = total * 0.7;
if (counts.conventional >= threshold)
return "conventional";
if (counts.ticketPrefixed >= threshold)
return "ticket-prefixed";
if (counts.freeForm >= threshold)
return "free-form";
return "mixed";
}
/** Count classified subjects once so rendering and dominance decisions use the same sample. */
function countsFor(messages) {
return messages.reduce((counts, message) => {
if (message.kind === "conventional")
counts.conventional += 1;
if (message.kind === "ticket-prefixed")
counts.ticketPrefixed += 1;
if (message.kind === "free-form")
counts.freeForm += 1;
return counts;
}, { conventional: 0, ticketPrefixed: 0, freeForm: 0 });
}
function exampleFor(messages, status) {
if (status === "mixed" || status === "insufficient-history") {
return messages[0]?.subject ?? null;
}
return (messages.find((message) => message.kind === status)?.subject ??
messages[0]?.subject ??
null);
}
function detectCommitConventions(targetRoot) {
const root = resolve(targetRoot);
const result = spawnSync("git", ["-C", root, "log", "-n", "100", "--format=%B%x1e"], {
encoding: "utf-8",
timeout: 5000,
maxBuffer: 1024 * 1024,
});
if (result.error || result.status !== 0) {
return emptyDetection(false);
}
const messages = parseMessages(result.stdout);
if (messages.length < 10) {
return {
...emptyDetection(true),
total: messages.length,
exampleSubject: messages[0]?.subject ?? null,
};
}
const counts = countsFor(messages);
const status = dominantStatus(counts, messages.length);
const allMessageText = messages
.map((message) => `${message.subject}\n${message.body}`)
.join("\n");
return {
status,
total: messages.length,
counts,
conventionalTypes: conventionalTypes(messages),
subjectLengthP95: percentile95(messages.map((message) => message.subject.length)),
bodiesUsed: messages.some((message) => message.body.length > 0),
coAuthoredBy: /^co-authored-by:/imu.test(allMessageText),
signedOffBy: /^signed-off-by:/imu.test(allMessageText),
ticketPrefixPattern: ticketPrefixPattern(messages),
exampleSubject: exampleFor(messages, status),
gitAvailable: true,
};
}
/** Render style counts as evidence so generated guidance is reviewable instead of opaque. */
function renderCounts(detection) {
return [
`- Conventional commits: ${detection.counts.conventional}`,
`- Ticket-prefixed subjects: ${detection.counts.ticketPrefixed}`,
`- Free-form subjects: ${detection.counts.freeForm}`,
];
}
function renderObservedMetadata(detection) {
const lines = [
`- Sampled commits: ${detection.total}`,
`- Subject length p95: ${detection.subjectLengthP95 ?? "n/a"} characters`,
`- Bodies observed: ${detection.bodiesUsed ? "yes" : "no"}`,
`- Co-authored-by trailers observed: ${detection.coAuthoredBy ? "yes" : "no"}`,
`- Signed-off-by trailers observed: ${detection.signedOffBy ? "yes" : "no"}`,
];
if (detection.exampleSubject) {
lines.push(`- Example from history: \`${detection.exampleSubject}\``);
}
return lines;
}
/** Render editable fallback guidance when history is unavailable or too small to trust. */
function renderStub(detection) {
const reason = detection.gitAvailable
? `only ${detection.total} recent commits found`
: "git history unavailable";
return [
"# Git Commit Instructions",
"",
"<!-- goat-flow: generated stub - insufficient git history; please edit -->",
"",
"Use concise conventional commits unless this project documents a different rule.",
"",
"## Format",
"",
"- Prefer `type(scope): subject` or `type: subject`.",
"- Keep subjects imperative, concrete, and under 72 characters when practical.",
"- Add a body when the motivation is not obvious from the subject.",
"",
`Stub reason: ${reason}.`,
"",
].join("\n");
}
function renderGitCommitInstructions(detection) {
if (detection.status === "insufficient-history")
return renderStub(detection);
const lines = [
"# Git Commit Instructions",
"",
"<!-- goat-flow: generated from recent git history; review and edit for project policy -->",
"",
"## Observed Commit Style",
"",
...renderCounts(detection),
"",
];
if (detection.status === "conventional") {
lines.push("Use conventional commits because at least 70% of sampled subjects matched that style.", "", "## Format", "", "- Use `type(scope): subject` or `type: subject`.", `- Observed types: ${detection.conventionalTypes.join(", ") || "none"}.`, "- Keep the subject concrete: name the behavior, file family, or command that changed.", "- Add a body when the subject names more than one axis or the motivation is not obvious.", "");
}
else if (detection.status === "ticket-prefixed") {
lines.push("Use ticket-prefixed commit subjects because at least 70% of sampled subjects matched that style.", "", "## Format", "", `- Prefix subjects with a ticket matching \`${detection.ticketPrefixPattern ?? "^[A-Z][A-Z0-9]+-\\d+"}\`.`, "- Keep the subject concrete and imperative after the ticket prefix.", "- Add a body when the change spans multiple behaviors or the motivation is not obvious.", "");
}
else if (detection.status === "free-form") {
lines.push("Recent history is mostly free-form. Keep that style concise unless the project owner chooses a stricter convention.", "", "## Format", "", "- Use a concrete imperative subject.", "- Avoid vague subjects that only say the change improves or updates something.", "- Add a body when the change spans multiple behaviors or the motivation is not obvious.", "");
}
else {
lines.push("TODO: choose the project commit style. Recent history is mixed, so goat-flow did not pick one silently.", "", "## Observed Patterns", "", ...renderCounts(detection), "", "After choosing a style, replace this TODO section with the project rule.", "");
}
lines.push("## Evidence", "", ...renderObservedMetadata(detection), "");
return lines.join("\n");
}
/**
* Create the canonical commit-guidance doc from detected history, never clobbering existing rules.
*
* Writes docs/coding-standards/git-commit.md (creating parent dirs) only when it is absent, so a
* project owner's hand-maintained conventions are preserved across re-installs. No .github/
* directory is required - the doc lives under docs/ regardless of which agent is installed.
*
* @param targetRoot - Project root to write commit guidance into; resolved to an absolute path.
* @returns A result describing whether the doc was written or skipped because one already exists, plus the detection used.
*/
export function ensureGitCommitInstructions(targetRoot) {
const root = resolve(targetRoot);
const outputPath = join(root, GIT_COMMIT_INSTRUCTIONS_PATH);
if (existsSync(outputPath)) {
return {
status: "skipped-existing",
path: GIT_COMMIT_INSTRUCTIONS_PATH,
detection: null,
};
}
const detection = detectCommitConventions(root);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, renderGitCommitInstructions(detection), "utf-8");
return {
status: "written",
path: GIT_COMMIT_INSTRUCTIONS_PATH,
detection,
};
}
//# sourceMappingURL=commit-guidance.js.map