UNPKG

paqad-ai

Version:

Spec-driven development framework — AI agents that think before they type

182 lines (180 loc) • 6.5 kB
import { createRequire as __paqadCreateRequire } from 'module'; const require = __paqadCreateRequire(import.meta.url); import { appendFeatureStageRow, readFeatureDelivery, readFeatureStageUnit } from "../chunk-5M55PDWL.js"; import "../chunk-TINPSX3N.js"; import "../chunk-BTDDEK7C.js"; import { featureBranch, isBundleClosed, isBundleMaterialized, isFeatureFinished, listFeatureDirs, readFeatureRecord, runningStageOfOtherSession, updateFeatureRecord } from "../chunk-7TBVBCDH.js"; import { rowRecordedAt } from "../chunk-RJMJYPHL.js"; import "../chunk-NCYDV2N5.js"; import "../chunk-D6VEUSB6.js"; import { readHeadRef } from "../chunk-BOEVU2BD.js"; import "../chunk-NJE7NS7E.js"; import "../chunk-ACJQW7SB.js"; import "../chunk-YA6SCYMC.js"; import "../chunk-Z3NGWHFX.js"; import "../chunk-YWAORJG6.js"; import "../chunk-Q353EDKY.js"; import "../chunk-SG6LXLDH.js"; import "../chunk-FMQ5YRSP.js"; import "../chunk-AZLA53A6.js"; import "../chunk-IHMBVS6H.js"; import { init_esm_shims } from "../chunk-IDFRWBMN.js"; // src/feature-evidence/abandon.ts init_esm_shims(); import { execFileSync } from "child_process"; var ABANDON_QUIET_MS = 24 * 60 * 60 * 1e3; var ABANDON_SESSION_ID = "paqad-housekeeping"; var MERGE_SUBJECT_MAX_COUNT = 20; var MERGE_SUBJECT_SKEW_MS = 24 * 60 * 60 * 1e3; var defaultGit = (projectRoot, args) => { try { return execFileSync("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return void 0; } }; function readBranchRefs(projectRoot, git) { const out = git(projectRoot, [ "for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes" ]); if (out === void 0) return null; const refs = { local: /* @__PURE__ */ new Set(), remote: /* @__PURE__ */ new Set() }; for (const ref of out.split("\n").filter(Boolean)) { if (ref.startsWith("refs/heads/")) { refs.local.add(ref.slice("refs/heads/".length)); } else if (ref.startsWith("refs/remotes/")) { const rest = ref.slice("refs/remotes/".length); const slash = rest.indexOf("/"); if (slash > 0) refs.remote.add(rest.slice(slash + 1)); } } return refs; } function rowSpan(rows) { let span = null; for (const row of rows) { const at = Date.parse(rowRecordedAt(row) ?? ""); if (Number.isNaN(at)) continue; span = span === null ? { oldest: at, newest: at } : { oldest: Math.min(span.oldest, at), newest: Math.max(span.newest, at) }; } return span; } function baseBranchOf(projectRoot, dirName) { return readFeatureRecord(projectRoot, dirName)?.base_branch ?? "main"; } function squashSubjectOnBase(projectRoot, dirName, number, since, git) { const base = baseBranchOf(projectRoot, dirName); const needle = `(#${number})`; const sinceIso = new Date(since - MERGE_SUBJECT_SKEW_MS).toISOString(); return [`refs/heads/${base}`, `refs/remotes/origin/${base}`].some((ref) => { const out = git(projectRoot, [ "log", ref, "--fixed-strings", `--grep=${needle}`, `--since=${sinceIso}`, `--max-count=${MERGE_SUBJECT_MAX_COUNT}`, "--format=%s" ]); return out !== void 0 && out.split("\n").some((subject) => subject.trimEnd().endsWith(needle)); }); } function confirmedMergeNote(projectRoot, dirName, since, git) { const pr = readFeatureDelivery(projectRoot, dirName).pr; const number = typeof pr?.number === "number" && Number.isInteger(pr.number) && pr.number > 0 ? pr.number : null; const note = `closed: pull request${number === null ? "" : ` #${number}`} merged`; if (pr?.state === "merged") return note; if (number === null) return null; return squashSubjectOnBase(projectRoot, dirName, number, since, git) ? note : null; } function isMerged(projectRoot, dirName, branch, git) { const delivery = readFeatureDelivery(projectRoot, dirName); if ((delivery.commit_split?.own_shas?.length ?? 0) === 0) return false; const base = baseBranchOf(projectRoot, dirName); return git(projectRoot, ["merge-base", "--is-ancestor", branch, base]) !== void 0; } function abandonReason(projectRoot, dirName, branch, refs, git) { if (!refs.local.has(branch) && !refs.remote.has(branch)) return "branch gone"; return isMerged(projectRoot, dirName, branch, git) ? "branch merged" : null; } function closeBundle(projectRoot, dirName, note, status, now) { appendFeatureStageRow( projectRoot, ABANDON_SESSION_ID, dirName, { kind: "close", event_status: null, note, agent: "orchestrator" }, now ); updateFeatureRecord(projectRoot, dirName, { status }, now); } function closeAbandonedBundles(projectRoot, options = {}) { const now = options.now ?? (() => /* @__PURE__ */ new Date()); const git = options.git ?? defaultGit; const current = readHeadRef(projectRoot).branch; const nowMs = now().getTime(); const quietBefore = nowMs - ABANDON_QUIET_MS; const closed = []; const candidates = listFeatureDirs(projectRoot).flatMap((dirName) => { if (!isBundleMaterialized(projectRoot, dirName) || isBundleClosed(projectRoot, dirName)) { return []; } if (isFeatureFinished(readFeatureRecord(projectRoot, dirName)?.status)) return []; const branch = featureBranch(projectRoot, dirName); if (branch === null || branch === current) return []; const rows = readFeatureStageUnit(projectRoot, dirName); const span = rowSpan(rows); if (span === null) return []; const mergedNote = confirmedMergeNote(projectRoot, dirName, span.oldest, git); if (mergedNote !== null) { if (runningStageOfOtherSession(rows, null, nowMs) === null) { closeBundle(projectRoot, dirName, mergedNote, "done", now); closed.push({ dir: dirName, branch, reason: "pull request merged" }); } return []; } if (span.newest >= quietBefore) return []; return [{ dirName, branch }]; }); if (candidates.length === 0) return closed; const refs = readBranchRefs(projectRoot, git); if (refs === null) return closed; for (const { dirName, branch } of candidates) { const reason = abandonReason(projectRoot, dirName, branch, refs, git); if (reason === null) continue; closeBundle(projectRoot, dirName, `abandoned: ${reason}, no row for 24h`, "abandoned", now); closed.push({ dir: dirName, branch, reason }); } return closed; } export { ABANDON_QUIET_MS, ABANDON_SESSION_ID, MERGE_SUBJECT_MAX_COUNT, MERGE_SUBJECT_SKEW_MS, closeAbandonedBundles };