@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
492 lines (491 loc) • 23.9 kB
JavaScript
import * as fs from "fs";
import { addTask, readTasks, resolveTaskItem, updateTask, } from "../data/access.js";
import { parseGithubIssueUrl, resolveProjectGithubRepo } from "./github.js";
import { getProactivityLevelForTask, shouldAutoCaptureTaskForLevel, hasExecutionIntent, hasDiscoveryIntent, hasExplicitTaskSignal, hasSuppressTaskIntent, hasCodeChangeContext } from "../proactivity.js";
import { getWorkflowPolicy } from "../shared/governance.js";
import { debugLog, sessionMarker } from "../shared.js";
import { errorMessage } from "../utils.js";
import { clankerEnabled, rowTitle } from "../clanker.js";
const ACTION_PREFIX_RE = /^(?:please\s+|can you\s+|could you\s+|would you\s+|i want you to\s+|i want to\s+|let(?:'|’)s\s+|lets\s+|help me\s+)/i;
const EXPLICIT_TASK_PREFIX_RE = /^(?:add(?:\s+(?:this|that|it))?\s+(?:to\s+(?:the\s+)?)?(?:task|todo(?:\s+list)?|task(?:\s+list)?)|add\s+(?:a\s+)?task|put(?:\s+(?:this|that|it))?\s+(?:in|on)\s+(?:the\s+)?(?:task|todo(?:\s+list)?|task(?:\s+list)?))\s*(?::|-|,)?\s*/i;
const NON_ACTIONABLE_RE = /\b(brainstorm|idea|ideas|maybe|what if|should we|could we|would it make sense|question|explain|why is|how does)\b/i;
// Conversational noise: only matches when the ENTIRE prompt is a short ack/reaction (under 40 chars).
// This avoids rejecting "sure, go ahead and fix the build" or "great, now update the docs".
const CONVERSATIONAL_NOISE_RE = /^(ok|okay|yeah|yep|nah|nope|hi|hey|ss|bro|lol|lmao|got it|sounds good|perfect|great|sure|thanks|thank you|ty|np|no problem|alright|cool|nice|damn|wtf|omg|fok)[\s!.?,]*$/i;
// Raw system/SQL error fragment signals — patterns that only appear in error output, never real task requests.
// Intentionally does NOT include "line \d+" or "incorrect syntax" alone (too broad — they appear in dev prompts).
const RAW_MESSAGE_SIGNALS_RE = /\b(msg \d+, level \d+|cannot insert the value null|insufficient result space|uniqueidentifier value to char|pgevision-prod|task-notification|tool-use-id|toolu_0[a-z0-9])\b/i;
// Pasted terminal banners and web-page chrome. Observed verbatim as committed tasks: a whole
// GitHub page starting "Skip to content ... Repository navigation Code Issues Pull requests",
// and "Windows PowerShell Copyright (C) Microsoft Corporation". None of it is a request.
const PASTED_CONTENT_RE = new RegExp([
// Web page chrome
"^\\s*skip to (?:main )?content\\b",
"\\brepository navigation\\b",
"\\bjump to (?:content|bottom)\\b",
"\\byou signed (?:in|out) with another tab\\b",
"\\bcode\\s+issues\\s+pull requests\\b",
// Terminal banners and prompts
"^\\s*windows powershell\\b",
"\\bcopyright \\(c\\) microsoft corporation\\b",
"\\bmicrosoft windows \\[version\\b",
"\\ball rights reserved\\b",
"\\bps [a-z]:\\\\",
"^[a-z]:\\\\[^\\n]*>",
"\\blast login: \\w{3} \\w{3}",
"npm ERR!",
"Traceback \\(most recent call last\\)",
].join("|"), "i");
// Chat filler markers. Unlike CONVERSATIONAL_NOISE_RE these match anywhere in the prompt,
// because the observed echoes wrap filler around an incidental verb — "Its literally just the
// start of the day LMAO" clears the substance floor on "start" but is not a task.
const CONVERSATIONAL_FILLER_RE = /\b(?:lmao|lmfao|rofl|lol+|haha+|idk|idc|tbh|ngl|smh|wtf|meh|yolo)\b/i;
// Claude Code wraps bracketed-paste input as <pasted_content id="…">…</pasted_content id="…">:
// any multi-line paste, every message typed in from the phone, and every message another
// agent relays into the pane. The text inside cannot be told apart from someone else's
// words (a relayed conductor message rewrote a task's Context three times), so it never
// files or touches a task; only what was typed outside the wrapper counts.
const PASTED_CONTENT_WRAPPER_RE = /<pasted_content\b[^>]*>[\s\S]*?<\/pasted_content\b[^>]*>/g;
// Relays may name one sender ("From tidy-phren:") or a computer and agent
// ("From macbook android-codex:"). Keep "From now on" as a request.
// A two-word sender needs the colon; longer names need a parenthesized handle.
// "From the settings page, add …" stays a request. A message the conductor
// sends opens with "Conductor:" or "Conductor,".
const RELAYED_MESSAGE_RE = /^\s*(?:conductor\s*[,:]|from\s+(?:the\s+)?(?!(?:now|here|there|then|scratch|today|tomorrow)\b)(?:[\w.-]+\s*[,:]|[\w.-]+\s+[\w.-]+\s*:|[\w.-]+(?:[ \t]+[\w.-]+)*[ \t]+\([\w.-]+\)[ \t]*:))/i;
// Dispatch returns are typed into an idle agent as ordinary prompts.
const DISPATCH_RETURN_NOTICE_RE = /^\s*returns?:\s+[^\n]+\bCall dispatch_returns\.\s*$/i;
// A dispatched or scheduled worker's first prompt names its brief file
// (launch-brief.ts). The dispatch already tracks that work, so the worker's
// own hook must not file it again: every dispatched Claude filed one task.
const LAUNCH_BRIEF_PROMPT_RE = /^\s*Read and follow the brief in \/[^\n]+$/;
// Completed-work reports can arrive without a relay prefix. Only a status
// reporting a commit or push, or ending in an opened PR, is excluded;
// imperative requests stay eligible even when they mention a pushed commit.
const AGENT_STATUS_REPORT_RE = /(?:\b(?:committed|pushed)\s+(?:as\s+)?[0-9a-f]{7,40}\b|\bPR\s+#\d+\s+opened\s*[.!]?\s*$)/i;
// Frames another agent or the harness put in the prompt: a cross-session message, a
// sub-agent hand-back, a delivery/idle notice, a task or system notification, a system
// reminder. Not the person's request. Matched anywhere, since a harness may put the
// person's own words inside or after one of these, and a frame is never worth a task.
const AGENT_FRAME_RE = /<\/?(?:agent-message|cross-session-message|task-notification|system-reminder|system-notification|phren-notice|command-name|command-message)\b|\[(?:SYSTEM NOTIFICATION|Cross-session (?:delivery|idle) notice)\b/i;
// A reply to the agent — "Yep /herdr the phren agent is there" — starts with an
// acknowledgement and asks for nothing; a question ends with one.
const REPLY_OPENER_RE = /^(?:yep|yeah|yes|yup|ya|nah|no|nope|ok|okay|right|correct|exactly|indeed|true|sure|fine|agreed|(?:just\s+)?curious|(?:i(?:'|’)?m\s+)?wondering|i wonder)\b/i;
const QUESTION_RE = /\?\s*$/;
/** What the person typed: the prompt without pasted blocks. */
export function typedPromptText(prompt) {
return prompt.includes("<pasted_content") ? prompt.replace(PASTED_CONTENT_WRAPPER_RE, " ").trim() : prompt;
}
function isAgentStatusReportPrompt(prompt) {
const trimmed = prompt.trim();
if (ACTION_PREFIX_RE.test(trimmed) || /^(?:can|could|would|will|should)\b/i.test(trimmed)
|| ACTIONABLE_RE.exec(trimmed)?.index === 0)
return false;
return AGENT_STATUS_REPORT_RE.test(trimmed);
}
/** A frame from another agent or the harness, or a message relayed from one,
* rather than something the person asked. */
export function isAgentFramePrompt(prompt) {
return AGENT_FRAME_RE.test(prompt) || RELAYED_MESSAGE_RE.test(prompt)
|| DISPATCH_RETURN_NOTICE_RE.test(prompt) || LAUNCH_BRIEF_PROMPT_RE.test(prompt)
|| isAgentStatusReportPrompt(prompt);
}
/** A reply or a question: conversation with the agent, not a request for work. */
function isConversationalTurn(prompt) {
if (QUESTION_RE.test(prompt))
return true;
return REPLY_OPENER_RE.test(prompt) && !hasExplicitTaskSignal(prompt) && !hasExecutionIntent(prompt);
}
const ACTIONABLE_RE = /\b(add|build|change|complete|continue|create|delete|fix|implement|improve|investigate|make|move|refactor|remove|rename|repair|ship|start|update|wire)\b/i;
const CONTINUE_RE = /\b(continue|keep going|finish|resume|pick up|work on that|that task)\b/i;
const GITHUB_URL_RE = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/\d+(?:[?#][^\s]*)?/g;
const GITHUB_ISSUE_RE = /(^|[^\w/])#(\d+)\b/g;
const TASK_STOP_WORDS = new Set([
"about",
"after",
"again",
"also",
"auto",
"automatic",
"because",
"before",
"code",
"phren",
"current",
"during",
"feature",
"from",
"have",
"into",
"just",
"like",
"make",
"more",
"need",
"really",
"should",
"some",
"something",
"stuff",
"task",
"tasks",
"that",
"them",
"then",
"this",
"thing",
"want",
"with",
"work",
]);
function taskSessionPath(phrenPath, sessionId) {
return sessionMarker(phrenPath, `task-${sessionId}.json`);
}
function readTaskSessionState(phrenPath, sessionId) {
const file = taskSessionPath(phrenPath, sessionId);
if (!fs.existsSync(file))
return null;
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
catch (err) {
debugLog(`task lifecycle read session ${sessionId}: ${errorMessage(err)}`);
return null;
}
}
function writeTaskSessionState(phrenPath, state) {
const file = taskSessionPath(phrenPath, state.sessionId);
fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
}
function clearTaskSessionState(phrenPath, sessionId) {
const file = taskSessionPath(phrenPath, sessionId);
try {
if (fs.existsSync(file))
fs.unlinkSync(file);
}
catch (err) {
debugLog(`task lifecycle clear session ${sessionId}: ${errorMessage(err)}`);
}
}
function getTaskMode(phrenPath) {
return getWorkflowPolicy(phrenPath).taskMode;
}
// Hard substance floor — applies before any intent-specific logic so that conversational
// fragments slipping past the noise regex (e.g. "Here's the thing", "I just clicked on to
// this page", "<") never become tasks even at proactivityTasks=high. A prompt clears the
// floor only if it has enough words AND has at least one signal: an actionable verb, a
// path-like fragment (./, /), a reference (#123 or a 4+ digit ticket number), a file
// extension, or a URL.
const MIN_TASK_PROMPT_WORDS = 4;
const MIN_TASK_PROMPT_CHARS = 12;
const TASK_SIGNAL_RE = /\b(?:fix|add|update|remove|delete|check|run|build|test|ship|implement|investigate|review|audit|create|change|refactor|rename|deploy|merge|migrate|wire|integrate|debug|explore|evaluate|compare|analy[sz]e|assess|consider|design|document|plan|prototype|prepare|configure|enable|disable|publish|release|rollback|verify|validate|profile|optimi[sz]e|harden|automate|backport|cleanup|cleanse|polish|tune|land)\b|[/\\][\w.\-]+|#\d+|\b\d{4,}\b|\.[a-z]{1,4}\b|https?:\/\//i;
function hasMinimumTaskSubstance(prompt) {
const trimmed = prompt.trim();
if (trimmed.length < MIN_TASK_PROMPT_CHARS)
return false;
const words = trimmed.split(/\s+/).filter((w) => /\w/.test(w));
if (words.length < MIN_TASK_PROMPT_WORDS)
return false;
return TASK_SIGNAL_RE.test(trimmed);
}
function isActionablePrompt(prompt, intent) {
const normalized = prompt.trim();
if (!normalized)
return false;
if (NON_ACTIONABLE_RE.test(normalized))
return false;
// Always reject conversational noise and raw system/SQL fragments regardless of intent.
if (CONVERSATIONAL_NOISE_RE.test(normalized))
return false;
if (RAW_MESSAGE_SIGNALS_RE.test(normalized))
return false;
// Task auto-capture writes the prompt verbatim onto a `Context:` line, so anything that is
// not a request becomes a permanent task. Pasted pages/terminals and chat filler never are.
if (PASTED_CONTENT_RE.test(normalized))
return false;
if (CONVERSATIONAL_FILLER_RE.test(normalized))
return false;
if (isAgentFramePrompt(normalized))
return false;
if (isConversationalTurn(normalized))
return false;
// Substance floor — independent of intent / proactivity. Short utterances without
// any actionable signal are conversational fragments, not tasks.
if (!hasMinimumTaskSubstance(normalized))
return false;
if (intent === "general")
return ACTIONABLE_RE.test(normalized);
return true;
}
function normalizeTaskSummary(prompt) {
const withoutGithub = prompt
.replace(GITHUB_URL_RE, " ")
.replace(GITHUB_ISSUE_RE, "$1 ")
.replace(/\s+/g, " ")
.trim();
const stripped = withoutGithub.replace(ACTION_PREFIX_RE, "").trim();
const withoutTaskPrefix = stripped.replace(EXPLICIT_TASK_PREFIX_RE, "").trim();
const taskSource = withoutTaskPrefix || stripped;
// A sentence ends at ./!/? before whitespace or the end, not inside "0.3.44" or "task-lifecycle.ts".
const firstClause = taskSource.split(/\n|[.!?](?=\s|$)/)[0]?.trim() || taskSource;
const cleaned = firstClause
.replace(/^to\s+/i, "")
.replace(/\s+/g, " ")
.replace(/^["'`]+|["'`]+$/g, "")
.trim();
const capped = cleaned.length > 110 ? `${cleaned.slice(0, 109).trimEnd()}…` : cleaned;
if (!capped)
return "Follow up on current work";
return capped.charAt(0).toUpperCase() + capped.slice(1);
}
function tokenizeTaskText(value) {
return value
.toLowerCase()
.replace(/<!--.*?-->/g, " ")
.replace(/[`"'.,!?()[\]{}:/\\]/g, " ")
.split(/\s+/)
.filter((token) => token.length >= 4 && !TASK_STOP_WORDS.has(token));
}
function overlapScore(prompt, item) {
const promptTokens = new Set(tokenizeTaskText(prompt));
if (promptTokens.size === 0)
return 0;
const itemTokens = tokenizeTaskText(item.line);
let score = 0;
for (const token of itemTokens) {
if (promptTokens.has(token))
score += 1;
}
if (prompt.toLowerCase().includes(item.line.toLowerCase()))
score += 3;
return score;
}
function matchExistingActiveTask(prompt, activeItems) {
if (activeItems.length === 0)
return null;
if (activeItems.length === 1 && CONTINUE_RE.test(prompt))
return activeItems[0];
const ranked = activeItems
.map((item) => ({ item, score: overlapScore(prompt, item) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score);
if (ranked.length === 0)
return null;
if (ranked[0].score >= 2 && (ranked.length === 1 || ranked[0].score > ranked[1].score)) {
return ranked[0].item;
}
return null;
}
function resolveTrackedSessionTask(phrenPath, state) {
const match = state.stableId ? `bid:${state.stableId}` : state.item;
const resolved = resolveTaskItem(phrenPath, state.project, match);
return resolved.ok ? resolved.data : null;
}
function extractGithubMetadata(phrenPath, project, prompt) {
const repo = resolveProjectGithubRepo(phrenPath, project);
for (const match of prompt.matchAll(GITHUB_URL_RE)) {
const parsed = parseGithubIssueUrl(match[0]);
if (!parsed)
continue;
if (repo && parsed.repo && parsed.repo !== repo)
continue;
return {
github_issue: parsed.issueNumber,
github_url: parsed.url,
};
}
if (!repo)
return {};
const issueMatch = GITHUB_ISSUE_RE.exec(prompt);
GITHUB_ISSUE_RE.lastIndex = 0;
if (!issueMatch)
return {};
return { github_issue: Number.parseInt(issueMatch[2], 10) };
}
function buildSuggestionNotice(project, line, issueMeta) {
const githubLine = issueMeta.github_url
? `Suggested link: ${issueMeta.github_url}`
: issueMeta.github_issue
? `Suggested GitHub link: #${issueMeta.github_issue}`
: "";
return [
"<phren-notice>",
`Task suggestion for ${project}:`,
`- ${line}`,
...(githubLine ? [githubLine] : []),
"<phren-notice>",
];
}
function persistTaskAttachment(phrenPath, sessionId, project, item, summary, mode) {
writeTaskSessionState(phrenPath, {
sessionId,
project,
stableId: item.stableId,
item: item.line,
summary,
mode,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
export function handleTaskPromptLifecycle(args) {
const mode = getTaskMode(args.phrenPath);
if (mode === "off" || mode === "manual" || !args.project || !args.sessionId) {
return { mode, noticeLines: [] };
}
// A sub-agent hand-back, a system notification or a harness reminder arrives as a
// prompt but nobody typed it: it never creates, moves or reuses a task.
if (isAgentFramePrompt(args.prompt)) {
debugLog(`task lifecycle skipped ${args.project}: machine-originated prompt`);
return { mode, noticeLines: [] };
}
const prompt = typedPromptText(args.prompt);
// Suppression takes absolute priority — user explicitly said not to create a task.
if (hasSuppressTaskIntent(prompt)) {
debugLog(`task lifecycle suppressed ${args.project}: suppress-task intent detected`);
return { mode, noticeLines: [] };
}
if (!isActionablePrompt(prompt, args.intent)) {
return { mode, noticeLines: [] };
}
const taskLevel = args.taskLevel ?? getProactivityLevelForTask(args.phrenPath);
if (mode === "auto" && !shouldAutoCaptureTaskForLevel(taskLevel, prompt)) {
debugLog(`task lifecycle skipped ${args.project}: task proactivity=${taskLevel}`);
return { mode, noticeLines: [] };
}
const parsed = readTasks(args.phrenPath, args.project);
if (!parsed.ok)
return { mode, noticeLines: [] };
const summary = normalizeTaskSummary(prompt);
const issueMeta = extractGithubMetadata(args.phrenPath, args.project, prompt);
const trackedState = readTaskSessionState(args.phrenPath, args.sessionId);
const trackedItem = trackedState && trackedState.project === args.project
? resolveTrackedSessionTask(args.phrenPath, trackedState)
: null;
const activeItems = parsed.data.items.Active;
const reusable = trackedItem && trackedItem.section === "Active"
? trackedItem
: matchExistingActiveTask(prompt, activeItems);
if (mode === "suggest") {
const line = reusable?.line || summary;
return {
mode,
noticeLines: buildSuggestionNotice(args.project, line, issueMeta),
};
}
// Intent-aware auto mode: if the user is in discovery mode (brainstorming,
// exploring ideas) and NOT in execution mode (approving, committing to work,
// or performing code changes), create a speculative task and surface a suggestion.
if (mode === "auto" && !hasExecutionIntent(prompt) && !hasCodeChangeContext(prompt) && hasDiscoveryIntent(prompt)) {
const line = reusable?.line || summary;
debugLog(`task lifecycle auto→speculative ${args.project}: discovery intent detected`);
if (!reusable) {
addTask(args.phrenPath, args.project, summary, {
createdAt: new Date().toISOString(),
sessionId: args.sessionId,
speculative: true,
});
}
return {
mode: "auto",
noticeLines: buildSuggestionNotice(args.project, line, issueMeta),
};
}
const targetMatch = reusable?.stableId ? `bid:${reusable.stableId}` : reusable?.id;
if (!reusable) {
const add = addTask(args.phrenPath, args.project, summary, {
createdAt: new Date().toISOString(),
sessionId: args.sessionId,
});
if (!add.ok) {
debugLog(`task lifecycle add ${args.project}: ${add.error}`);
return { mode, noticeLines: [] };
}
}
// Something the person asked to track ("add this to task") goes to Active, as does
// a match on a task that is already there. What the hook picked up on its own is a
// guess about the work: it waits in Queue until someone takes it up.
// An existing task keeps its own Context and GitHub link: a later prompt that
// matches it is not a better description of the work.
const section = reusable || hasExplicitTaskSignal(prompt) ? "active" : "queue";
const update = updateTask(args.phrenPath, args.project, targetMatch || summary, reusable
? { section }
: { section, context: summary, replace_context: true, ...issueMeta });
if (!update.ok) {
debugLog(`task lifecycle update ${args.project}: ${update.error}`);
return { mode, noticeLines: [] };
}
const resolved = resolveTaskItem(args.phrenPath, args.project, targetMatch || summary);
if (!resolved.ok) {
debugLog(`task lifecycle resolve ${args.project}: ${resolved.error}`);
return { mode, noticeLines: [] };
}
persistTaskAttachment(args.phrenPath, args.sessionId, args.project, resolved.data, summary, "auto");
return {
mode,
noticeLines: [
"<phren-notice>",
// Clanker mode names the task by id and title; get_tasks or get_memory_detail has the rest.
`${section === "active" ? "Active" : "Queued"} task (${args.project}): ${clankerEnabled(args.phrenPath) && resolved.data.stableId ? `bid:${resolved.data.stableId} ${rowTitle(resolved.data.line)}` : resolved.data.line}`,
"<phren-notice>",
],
};
}
export function finalizeTaskSession(args) {
if (!args.sessionId || getTaskMode(args.phrenPath) !== "auto")
return;
const state = readTaskSessionState(args.phrenPath, args.sessionId);
if (!state || state.mode !== "auto")
return;
const match = state.stableId ? `bid:${state.stableId}` : state.item;
if (args.status === "saved-local" || args.status === "saved-pushed" || args.status === "no-upstream") {
// A saved turn is not finished work. The Stop hook runs after every turn, so
// completing here marked real work Done as soon as the next prompt came in.
// The task stays where it is; completion is explicit. Detaching it keeps the
// next prompt from rewriting its context unless that prompt matches it.
clearTaskSessionState(args.phrenPath, args.sessionId);
return;
}
if (args.status === "error") {
// Don't poison the task with transient git infrastructure failures. Common
// observed pattern: git add -A failing because of file locks / permissions
// on Windows mounts / sparse-checkout edge cases. The user's task is unrelated;
// promoting it to Active and marking it "Blocked: Command failed: git add -A"
// creates a permanent zombie that re-blocks every subsequent session.
if (isTransientGitFailure(args.detail)) {
debugLog(`task lifecycle ignored transient git failure for ${state.project}: ${args.detail}`);
// Keep session state intact — next clean session can complete normally.
return;
}
const blocked = updateTask(args.phrenPath, state.project, match, {
section: "active",
context: `Blocked: ${args.detail}`,
replace_context: true,
});
if (!blocked.ok) {
debugLog(`task lifecycle block ${state.project}: ${blocked.error}`);
return;
}
writeTaskSessionState(args.phrenPath, {
...state,
summary: `Blocked: ${args.detail}`,
updatedAt: new Date().toISOString(),
});
return;
}
}
const TRANSIENT_GIT_FAILURE_RE = /^Command failed:\s*git\s+(?:add|commit|push|stage|pull|fetch|stash)\b/i;
export function isTransientGitFailure(detail) {
if (!detail)
return false;
return TRANSIENT_GIT_FAILURE_RE.test(detail.trim());
}
/**
* Return the active TaskItem tracked for a session+project, if any.
* Used by mcp-finding.ts to link findings to active tasks.
*/
export function getActiveTaskForSession(phrenPath, sessionId, project) {
const state = readTaskSessionState(phrenPath, sessionId);
if (!state || state.project !== project)
return null;
return resolveTrackedSessionTask(phrenPath, state);
}