UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

179 lines (178 loc) • 9.85 kB
import { execFile } from "node:child_process"; import { lstat } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { nonInteractiveGitEnv } from "../utils-helpers.js"; import { countGit } from "./metrics.js"; /** * A worker whose turn ended is not always done. OpenCode in particular ends a * turn on a line like "The worktree lacks node_modules. Let me install * dependencies." with nothing run after it (hook-permission-mode, 2026-09-30: * returned done, its pane closed, 9 files of uncommitted work left behind). * Dispatch returns report such a turn as needs-you, never done, so its pane * is not closed and the dispatcher nudges it on. */ const exec = promisify(execFile); /** A sentence that announces work the turn never did: "Let me…", "I'll…", * "I need to…", or "Now" with a working verb. Whatever follows the subject in * HANDOVER, or a sentence waiting on something (CONDITIONAL, read by * `awaitedInReply` instead), is not one: a done worker that says "I'll wait * for your review" must not read as needs-you. "Let me know…" is a sign-off. */ const NEXT_STEP = /^(?:(?:ok(?:ay)?|alright|now|next|then|first|great|good)\b[,.!:]?\s+)*(?:let me(?! know)|let's|let us|i'll|i will|i'm going to|i am going to|i need to|i'm now|i am now|now i)\s+(.*)$/i; const NOW_DOING = /^now,?\s+(?:adding|applying|building|checking|cleaning|committing|compiling|creating|debugging|editing|fixing|generating|implementing|installing|investigating|launching|looking|moving|opening|patching|pushing|reading|rebuilding|refactoring|removing|rerunning|re-running|running|searching|setting|starting|testing|trying|updating|verifying|wiring|working|writing)\b/i; /** What a finished worker says it does next: wait, leave it to the owner, stop, be around. */ const HANDOVER = /^(?:just\s+)?(?:wait|await|leave|stop|hold|pause|be|hand|defer|stand|end|wrap|let you|keep an eye|check back|merge|ship|land|release|discuss|review|done|finished|ready)\b/i; const CONDITIONAL = /\b(?:once|when|whenever|after|as soon as|if|until|unless)\b/i; /** A sentence that waits on something other than the owner: "Now waiting on * the MacBook rerun.", "I'll push once it passes.", "Let's merge once CI is * green." (ios-fast-voice2, 2026-10-01: returned done and closed with its test * run going). Waiting for the owner's review or answer is a handover. */ const WAITS = /(?<!\bwhile\s)\b(?:wait(?:ing|s)?|await(?:ing|s)?)\s+(?:on|for|until)\b/i; /** "Once it passes", "when the build finishes": waiting on a job, not an "if". */ const ONCE_DONE = /\b(?:once|when|after|as soon as|until)\s+(?:it|its|the|that|this|they|both|all|my|ci)\b/i; const FUTURE = /\b(?:i'll|i will|we'll|we will|let's|let us|then i|will)\b/i; const OWNER = /\b(?:you|your|yours|owner|reviewers?|approv\w*|let me know)\b/i; const NEGATED = /\b(?:nothing|no longer|not|no need|without)\b|n't\b/i; /** The closing paragraph's prose lines: blank lines end a paragraph and fenced * code is skipped, so "Let me run:" before a closing code block is still in it. */ function closingLines(reply) { let fenced = false, lines = []; for (const raw of reply.replace(/\r/g, "").split("\n")) { const line = raw.trim(); if (/^(?:```|~~~)/.test(line)) { fenced = !fenced; continue; } if (fenced) continue; if (!line) { if (lines.length) lines = [...lines, ""]; continue; } if (lines.at(-1) === "") lines = []; lines.push(line); } return lines.filter(Boolean); } const LIST_ITEM = /^(?:[-*+>]|\d+[.)])\s+/; const sentences = (line) => line.replace(LIST_ITEM, "").replace(/[*_`#]+/g, "").replace(/[\u2018\u2019]/g, "'").trim() .split(/(?<=[.!?\u2026])\s+/).map(sentence => sentence.trim()).filter(Boolean); function nextStep(sentence) { if (CONDITIONAL.test(sentence)) return false; const step = NEXT_STEP.exec(sentence); return step ? !HANDOVER.test(step[1]) : NOW_DOING.test(sentence); } /** The first sentence of the reply's closing paragraph that announces a next * step ("Let me…", "I'll…", "Now running…"), else undefined. The closing line * counts even as a list item; earlier lines only as prose, so a summary list * that quotes an old step is not one. */ export function announcedNextStep(reply) { const lines = reply ? closingLines(reply) : []; const found = lines.flatMap((line, index) => index === lines.length - 1 || !LIST_ITEM.test(line) ? sentences(line) : []).find(nextStep); return found?.slice(0, 160); } /** The first sentence of the reply's closing paragraph that waits on work * still to finish ("Now waiting on the rerun.", "I'll push once it passes."), * else undefined. */ export function awaitedInReply(reply) { const found = (reply ? closingLines(reply) : []).flatMap(sentences).find(sentence => !OWNER.test(sentence) && !NEGATED.test(sentence) && (WAITS.test(sentence) || (ONCE_DONE.test(sentence) && FUTURE.test(sentence)))); return found?.slice(0, 160); } /** A checkout git could not read in time (or at all): its work is unknown, * so a turn there is never closed on the strength of it. */ export const UNKNOWN = "unknown"; /** Tracked files with uncommitted changes (staged or not) in the checkout at * `directory`. Untracked files are left out: a main checkout often holds the * owner's own (worktree folders, scratch files) and they are not the worker's. * Undefined when it is not a repository; UNKNOWN when git does not answer in * time or fails otherwise. */ export async function uncommittedFiles(directory, timeoutMs = 3000) { if (!path.isAbsolute(directory)) return undefined; countGit("worker-unfinished"); try { const { stdout } = await exec("git", ["-C", directory, "--no-pager", "-c", "core.fsmonitor=false", "status", "--porcelain=v1", "--untracked-files=no", "-z"], { timeout: timeoutMs, maxBuffer: 1_048_576, env: nonInteractiveGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_CONFIG_NOSYSTEM: "1" }), }); // -z: renames carry their source as a second entry with no status. return stdout.split("\0").filter(entry => /^[ MADRCTU?!]{2} /.test(entry)).length; } catch (error) { const failed = error; // Exit 128 naming no repository (or no such folder) is a plain answer, not a slow one. if (failed.code === 128 && !failed.killed && /not a git repository|cannot change to/i.test(String(failed.stderr ?? ""))) return undefined; return UNKNOWN; } } const recent = new Map(); /** `uncommittedFiles` for the returns loop, which asks about a finished * worker on every poll (15 s) for as long as its receipt is watched: a read * under a minute old answers. An UNKNOWN read is not kept, so the next poll * (and the close recheck) asks git again. */ export function recentUncommitted(directory, now = Date.now(), read = uncommittedFiles) { const hit = recent.get(directory); if (hit && now - hit.at < 60_000) return hit.files; const files = read(directory).catch(() => UNKNOWN); recent.delete(directory); recent.set(directory, { at: now, files }); while (recent.size > 256) recent.delete(recent.keys().next().value); void files.then(value => { if (value === UNKNOWN && recent.get(directory)?.files === files) recent.delete(directory); }); return files; } /** The checkout `directory` is in: the nearest folder holding `.git` (a * linked worktree's is a file), or undefined outside any. */ export async function checkoutRoot(directory) { if (!path.isAbsolute(directory)) return undefined; let folder = path.resolve(directory); for (let depth = 0; depth < 64; depth++) { if (await lstat(path.join(folder, ".git")).then(() => true, () => false)) return folder; const parent = path.dirname(folder); if (parent === folder) return undefined; folder = parent; } return undefined; } /** Whether any of `folders` (other panes' working folders) is in the same * checkout as `directory`. Uncommitted files there may be the owner's or a * sibling worker's, so they say nothing about this worker. */ export async function sharedCheckout(directory, folders) { const root = await checkoutRoot(directory); if (!root) return false; const others = [...new Set(folders.filter((folder) => typeof folder === "string" && path.isAbsolute(folder)))].slice(0, 64); for (const folder of others) if (folder.startsWith(root) && await checkoutRoot(folder) === root) return true; return false; } /** A pull request the reply itself names: a GitHub PR link or "PR #12". */ const PR_NAMED = /\/pull\/\d+|\bPR\s*#\d+/i; /** Why a turn that ended is not done: it announced a next step, or it left * uncommitted work with no PR reported or named in the reply (`unfinished`). `unchecked` when its * checkout could not be read: the turn may be done, but it is not safe to * close. Empty when it reads as done. */ export async function unfinishedTurn(input, uncommitted) { const step = announcedNextStep(input.reply); if (step) return { unfinished: `Stopped mid-task: ${step}` }; const waiting = awaitedInReply(input.reply); if (waiting) return { unfinished: `Stopped waiting: ${waiting}` }; if (input.prs?.length || PR_NAMED.test(input.reply ?? "") || !input.directory || !uncommitted) return {}; const files = await uncommitted(input.directory).catch(() => UNKNOWN); if (files === UNKNOWN) return { unchecked: true }; return files ? { unfinished: `Stopped with ${files} uncommitted file${files === 1 ? "" : "s"} and no PR.` } : {}; }