From bd3a35e13d7010d12f228a55825eab6ccf3c7919 Mon Sep 17 00:00:00 2001 From: oldschoola Date: Sat, 20 Jun 2026 15:55:47 -0700 Subject: [PATCH] fix(advisor): surface nested repo context --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/advisor/watchdog.ts | 16 ++- .../modes/components/status-line/component.ts | 102 ++++++++++---- .../modes/components/status-line/segments.ts | 5 +- .../src/modes/components/status-line/types.ts | 2 + .../prompts/advisor/active-repo-watchdog.md | 6 + .../src/prompts/system/active-repo-context.md | 4 + packages/coding-agent/src/sdk.ts | 23 +++- packages/coding-agent/src/system-prompt.ts | 87 ++++++++---- .../src/utils/active-repo-context.ts | 127 ++++++++++++++++++ .../test/advisor-watchdog.test.ts | 89 ++++++++++++ .../test/git-active-context.test.ts | 119 ++++++++++++++++ .../coding-agent/test/issue-953-repro.test.ts | 1 + .../test/status-line-model.test.ts | 1 + .../test/status-line-overflow.test.ts | 1 + .../test/status-line-path.test.ts | 45 ++++++- .../test/system-prompt-dedup.test.ts | 33 ++++- 17 files changed, 599 insertions(+), 66 deletions(-) create mode 100644 packages/coding-agent/src/prompts/advisor/active-repo-watchdog.md create mode 100644 packages/coding-agent/src/prompts/system/active-repo-context.md create mode 100644 packages/coding-agent/src/utils/active-repo-context.ts create mode 100644 packages/coding-agent/test/git-active-context.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 65b2ee52b4..4b3a86a56b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed advisor and status-line context when the session cwd is a parent of a single child git repo, so nested-repo work is surfaced before missing parent-cwd paths are treated as destroyed. ([#3130](https://github.com/can1357/oh-my-pi/issues/3130)) + ## [16.1.8] - 2026-06-20 ### Added diff --git a/packages/coding-agent/src/advisor/watchdog.ts b/packages/coding-agent/src/advisor/watchdog.ts index d3595ff81e..0d9f06575e 100644 --- a/packages/coding-agent/src/advisor/watchdog.ts +++ b/packages/coding-agent/src/advisor/watchdog.ts @@ -1,9 +1,23 @@ import * as os from "node:os"; import * as path from "node:path"; -import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; +import { getAgentDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils"; import { expandAtImports } from "../discovery/at-imports"; +import activeRepoWatchdogTemplate from "../prompts/advisor/active-repo-watchdog.md" with { type: "text" }; +import type { ActiveRepoContext } from "../utils/active-repo-context"; import { repo } from "../utils/git"; +function normalizePromptPath(value: string): string { + return value.replace(/\\/g, "/"); +} + +export function formatActiveRepoWatchdogPrompt(activeRepoContext: ActiveRepoContext): string { + return prompt + .render(activeRepoWatchdogTemplate, { + relativeRepoRoot: normalizePromptPath(activeRepoContext.relativeRepoRoot), + }) + .trim(); +} + /** * Discover and load WATCHDOG.md files walking up from cwd, project .omp folder, and user agent dir. * Returns formatted watchdog file blocks ready to be appended to the advisor system prompt. diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index 8f41bb1e95..f6b1832b9f 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -7,6 +7,7 @@ import { getProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { settings } from "../../../config/settings"; import type { AgentSession } from "../../../session/agent-session"; +import { type ActiveRepoContext, resolveActiveRepoContextSync } from "../../../utils/active-repo-context"; import * as git from "../../../utils/git"; import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color"; import { sanitizeStatusText } from "../../shared"; @@ -153,6 +154,12 @@ interface ContextUsageMemo { skillsRef: readonly any[] | undefined; } +interface ActiveRepoCache { + projectDir: string; + activeRepo: ActiveRepoContext | null; + effectiveGitCwd: string; +} + const EMPTY_MESSAGES: readonly AgentMessage[] = []; const STATUS_USAGE_START_DELAY_MS = 0; const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000; @@ -193,17 +200,20 @@ export class StatusLineComponent implements Component { #goalModeStatus: { enabled: boolean; paused: boolean } | null = null; #collabStatus: CollabStatus | null = null; #focusedAgentId: string | undefined; + #activeRepoCache: ActiveRepoCache | undefined; // Git status caching (1s TTL) #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null; + #cachedGitStatusCwd: string | undefined = undefined; #gitStatusLastFetch = 0; - #gitStatusInFlight = false; + #gitStatusInFlightCwd: string | undefined = undefined; // PR lookup caching (invalidated on branch/repo context changes) #cachedPr: { number: number; url: string } | null | undefined = undefined; #cachedPrContext: PrCacheContext | undefined = undefined; #prLookupInFlight = false; #defaultBranch?: string; + #defaultBranchCwd: string | undefined = undefined; #lastTokensPerSecond: number | null = null; #lastTokensPerSecondTimestamp: number | null = null; @@ -244,6 +254,18 @@ export class StatusLineComponent implements Component { ); } + #resolveActiveRepoCache(): ActiveRepoCache { + const projectDir = getProjectDir(); + if (this.#activeRepoCache?.projectDir === projectDir) { + return this.#activeRepoCache; + } + + const activeRepo = resolveActiveRepoContextSync(projectDir); + const effectiveGitCwd = activeRepo?.repoRoot ?? projectDir; + this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd }; + return this.#activeRepoCache; + } + /** * Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive * from it. `focusedAgentId` is the focused subagent id while the view is proxied, undefined for main. @@ -324,7 +346,8 @@ export class StatusLineComponent implements Component { return; } - const repository = git.repo.resolveSync(getProjectDir()); + const { effectiveGitCwd } = this.#resolveActiveRepoCache(); + const repository = git.repo.resolveSync(effectiveGitCwd); if (!repository) return; const watchPath = git.repo.isReftableSync(repository) @@ -379,17 +402,17 @@ export class StatusLineComponent implements Component { this.#cachedBranchCwd = undefined; this.#cachedPrContext = undefined; } - #getCurrentBranch(): string | null { + #getCurrentBranch(effectiveGitCwd?: string): string | null { if (!this.#gitEnabled()) return null; - const cwd = getProjectDir(); - if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === cwd) { + const gitCwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd; + if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === gitCwd) { return this.#cachedBranch; } - const head = git.head.resolveSync(cwd); + const head = git.head.resolveSync(gitCwd); const gitHeadPath = head?.headPath ?? null; - this.#cachedBranchCwd = cwd; + this.#cachedBranchCwd = gitCwd; this.#cachedBranchRepoId = gitHeadPath; if (!head) { this.#cachedBranch = null; @@ -401,12 +424,18 @@ export class StatusLineComponent implements Component { return this.#cachedBranch ?? null; } - #isDefaultBranch(branch: string): boolean { + #isDefaultBranch(branch: string, effectiveGitCwd: string): boolean { + if (this.#defaultBranchCwd !== effectiveGitCwd) { + this.#defaultBranch = undefined; + this.#defaultBranchCwd = effectiveGitCwd; + } + if (this.#defaultBranch === undefined) { this.#defaultBranch = "main"; + const lookupCwd = effectiveGitCwd; (async () => { - const resolved = await git.branch.default(getProjectDir()); - if (this.#disposed) return; + const resolved = await git.branch.default(lookupCwd); + if (this.#disposed || this.#defaultBranchCwd !== lookupCwd) return; if (resolved) { this.#defaultBranch = resolved; if (this.#onBranchChange) { @@ -418,32 +447,43 @@ export class StatusLineComponent implements Component { return branch === this.#defaultBranch; } - #getGitStatus(): { staged: number; unstaged: number; untracked: number } | null { + #getGitStatus(effectiveGitCwd?: string): { staged: number; unstaged: number; untracked: number } | null { if (!this.#gitEnabled()) return null; - if (this.#gitStatusInFlight || Date.now() - this.#gitStatusLastFetch < 1000) { + + const gitCwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd; + if (this.#gitStatusInFlightCwd !== undefined) { + return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null; + } + if (this.#cachedGitStatusCwd === gitCwd && Date.now() - this.#gitStatusLastFetch < 1000) { return this.#cachedGitStatus; } - this.#gitStatusInFlight = true; + this.#gitStatusInFlightCwd = gitCwd; (async () => { + let nextStatus: { staged: number; unstaged: number; untracked: number } | null = null; try { - this.#cachedGitStatus = await git.status.summary(getProjectDir()); + nextStatus = await git.status.summary(gitCwd); } catch { - this.#cachedGitStatus = null; + nextStatus = null; } finally { - this.#gitStatusLastFetch = Date.now(); - this.#gitStatusInFlight = false; + if (this.#gitStatusInFlightCwd === gitCwd) { + this.#cachedGitStatus = nextStatus; + this.#cachedGitStatusCwd = gitCwd; + this.#gitStatusLastFetch = Date.now(); + this.#gitStatusInFlightCwd = undefined; + } } })(); - return this.#cachedGitStatus; + return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null; } - #lookupPr(): { number: number; url: string } | null { + #lookupPr(effectiveGitCwd?: string): { number: number; url: string } | null { if (!this.#gitEnabled()) return null; - const branch = this.#getCurrentBranch(); + const gitCwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd; + const branch = this.#getCurrentBranch(gitCwd); const currentContext = branch ? createPrCacheContext(branch, this.#cachedBranchRepoId ?? null) : null; if (canReuseCachedPr(this.#cachedPr, this.#cachedPrContext, currentContext)) { @@ -452,13 +492,20 @@ export class StatusLineComponent implements Component { const stalePr = this.#cachedPr; - // Don't look up if no branch, detached HEAD, default branch, or already in flight - if (!branch || branch === "detached" || this.#isDefaultBranch(branch) || this.#prLookupInFlight) { + if (!branch) { + this.#cachedPr = null; + this.#cachedPrContext = undefined; + return null; + } + + // Don't look up if detached, default branch, or already in flight. + if (branch === "detached" || this.#isDefaultBranch(branch, gitCwd) || this.#prLookupInFlight) { return stalePr ?? null; } this.#prLookupInFlight = true; const lookupContext = currentContext; + const lookupCwd = gitCwd; // Fire async lookup, keep stale value visible until resolved (async () => { @@ -475,7 +522,7 @@ export class StatusLineComponent implements Component { }; try { // Requires `gh repo set-default` to be configured; fails gracefully if not - const result = await $`gh pr view --json number,url`.quiet().nothrow(); + const result = await $`gh pr view --json number,url`.cwd(lookupCwd).quiet().nothrow(); if (this.#disposed) return; if (result.exitCode !== 0) { setCachedPr(null); @@ -741,13 +788,14 @@ export class StatusLineComponent implements Component { contextPercent = collabState.contextUsage.percent ?? contextPercent; } - const gitBranch = includeGit || includePr ? this.#getCurrentBranch() : null; - const gitStatus = includeGit ? this.#getGitStatus() : null; - const gitPr = includePr ? this.#lookupPr() : null; - + const activeRepoCache = this.#resolveActiveRepoCache(); + const gitBranch = includeGit || includePr ? this.#getCurrentBranch(activeRepoCache.effectiveGitCwd) : null; + const gitStatus = includeGit ? this.#getGitStatus(activeRepoCache.effectiveGitCwd) : null; + const gitPr = includePr ? this.#lookupPr(activeRepoCache.effectiveGitCwd) : null; return { session: this.session, focusedAgentId: this.#focusedAgentId, + activeRepo: activeRepoCache.activeRepo, width, options: segmentOptions ?? {}, planMode: this.#planModeStatus, diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index cfb95d8e4e..b2a6504755 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -202,7 +202,7 @@ const pathSegment: StatusLineSegment = { render(ctx) { const opts = ctx.options.path ?? {}; - const projectDir = getProjectDir(); + const projectDir = ctx.activeRepo?.cwd ?? getProjectDir(); const { scratch, relative } = classifyProjectDir(projectDir); let pwd = projectDir; @@ -213,6 +213,9 @@ const pathSegment: StatusLineSegment = { pwd = stripDisplayRoot(pwd); } } + if (ctx.activeRepo) { + pwd = `${pwd} ↳ ${ctx.activeRepo.relativeRepoRoot}`; + } if (opts.abbreviate !== false) { pwd = shortenPath(pwd); } diff --git a/packages/coding-agent/src/modes/components/status-line/types.ts b/packages/coding-agent/src/modes/components/status-line/types.ts index 7f05ebcb5f..ff54027ca6 100644 --- a/packages/coding-agent/src/modes/components/status-line/types.ts +++ b/packages/coding-agent/src/modes/components/status-line/types.ts @@ -1,6 +1,7 @@ import type { CollabSessionState } from "../../../collab/protocol"; import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../../config/settings-schema"; import type { AgentSession } from "../../../session/agent-session"; +import type { ActiveRepoContext } from "../../../utils/active-repo-context"; export type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle }; @@ -47,6 +48,7 @@ export interface SegmentContext { session: AgentSession; /** Focused subagent id while the view is proxied at its session, undefined otherwise. */ focusedAgentId?: string | undefined; + activeRepo: ActiveRepoContext | null; width: number; options: StatusLineSegmentOptions; planMode: { diff --git a/packages/coding-agent/src/prompts/advisor/active-repo-watchdog.md b/packages/coding-agent/src/prompts/advisor/active-repo-watchdog.md new file mode 100644 index 0000000000..416074175e --- /dev/null +++ b/packages/coding-agent/src/prompts/advisor/active-repo-watchdog.md @@ -0,0 +1,6 @@ +Especially pay attention to: + +The session cwd is outside git, and exactly one direct child git repository was detected at `{{relativeRepoRoot}}`. + +Paths under `{{relativeRepoRoot}}/` are the active project. Do not claim work is missing, destroyed, or absent at the parent cwd until you have checked under `{{relativeRepoRoot}}/`. + diff --git a/packages/coding-agent/src/prompts/system/active-repo-context.md b/packages/coding-agent/src/prompts/system/active-repo-context.md new file mode 100644 index 0000000000..f7d89998bf --- /dev/null +++ b/packages/coding-agent/src/prompts/system/active-repo-context.md @@ -0,0 +1,4 @@ + +The session cwd is outside git. Exactly one direct child git repository was detected at `{{relativeRepoRoot}}`. +Paths under `{{relativeRepoRoot}}/` are the active project for this session. Parent-cwd misses are inconclusive until checking under `{{relativeRepoRoot}}/`. + diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index d4133af98c..ee0c68bfe1 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -24,7 +24,7 @@ import { FALLBACK_DIALECT, preferredDialect } from "@oh-my-pi/pi-catalog/identit import type { Component } from "@oh-my-pi/pi-tui"; import { $env, $flag, getAgentDir, getProjectDir, logger, postmortem, prompt, Snowflake } from "@oh-my-pi/pi-utils"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; -import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles } from "./advisor"; +import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles, formatActiveRepoWatchdogPrompt } from "./advisor"; import { type AsyncJob, AsyncJobManager } from "./async"; import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller"; import { loadCapability } from "./capability"; @@ -182,6 +182,7 @@ import { getImageGenTools } from "./tools/image-gen"; import { wrapToolWithMetaNotice } from "./tools/output-meta"; import { queueResolveHandler } from "./tools/resolve"; import { ttsTool } from "./tools/tts"; +import { resolveActiveRepoContext } from "./utils/active-repo-context"; import { EventBus } from "./utils/event-bus"; import { buildNamedToolChoice } from "./utils/tool-choice"; import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree"; @@ -1132,6 +1133,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ? Promise.resolve(options.contextFiles) : logger.time("discoverContextFiles", discoverContextFiles, cwd, agentDir); contextFilesPromise.catch(() => {}); + const activeRepoContextPromise = logger.time("resolveActiveRepoContext", async () => { + try { + return await resolveActiveRepoContext(cwd); + } catch (err) { + logger.debug("Failed to resolve active repo context", { err: String(err) }); + return null; + } + }); + activeRepoContextPromise.catch(() => {}); const watchdogFilesPromise = logger.time("discoverWatchdogFiles", () => discoverWatchdogFiles(cwd, agentDir)); watchdogFilesPromise.catch(() => {}); const promptTemplatesPromise = options.promptTemplates @@ -1377,10 +1387,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } return result; }; - const [contextFiles, resolvedWorkspaceTree, watchdogFiles] = await Promise.all([ + const [contextFiles, resolvedWorkspaceTree, watchdogFiles, activeRepoContext] = await Promise.all([ contextFilesPromise, raceWithDeadline("buildWorkspaceTree", workspaceTreePromise), watchdogFilesPromise, + activeRepoContextPromise, ]); let agent: Agent; @@ -2197,6 +2208,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} memoryRootEnabled: memoryBackend.id === "local", model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined, personality: agentKind === "sub" ? "none" : settings.get("personality"), + activeRepoContext, }); if (options.systemPrompt === undefined) { @@ -2584,10 +2596,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} .filter((tool): tool is Tool => tool != null) .map(wrapToolWithMetaNotice); - let advisorWatchdogPrompt: string | undefined; - if (watchdogFiles && watchdogFiles.length > 0) { - advisorWatchdogPrompt = watchdogFiles.join("\n\n"); + const advisorWatchdogPrompts = [...watchdogFiles]; + if (activeRepoContext) { + advisorWatchdogPrompts.push(formatActiveRepoWatchdogPrompt(activeRepoContext)); } + const advisorWatchdogPrompt = advisorWatchdogPrompts.length > 0 ? advisorWatchdogPrompts.join("\n\n") : undefined; // Owned only when this session created the manager; subagents receive a // parent's manager via `options.mcpManager` and MUST NOT disconnect it. const ownedMcpManager = options.mcpManager ? undefined : mcpManager; diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 9a258d6fcb..98dfe78f6a 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -16,6 +16,7 @@ import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile import { expandAtImports } from "./discovery/at-imports"; import { loadSkills, type Skill } from "./extensibility/skills"; import { hasObsidian } from "./internal-urls/vault-protocol"; +import activeRepoContextTemplate from "./prompts/system/active-repo-context.md" with { type: "text" }; import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" }; import defaultPersonality from "./prompts/system/personalities/default.md" with { type: "text" }; import friendlyPersonality from "./prompts/system/personalities/friendly.md" with { type: "text" }; @@ -23,6 +24,7 @@ import pragmaticPersonality from "./prompts/system/personalities/pragmatic.md" w import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" }; import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" }; import { shortenPath } from "./tools/render-utils"; +import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context"; import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree"; /** Bundled personality specs, keyed by the `personality` setting value. */ @@ -90,6 +92,19 @@ function firstNonEmpty(...values: (string | undefined | null)[]): string | null return null; } +function normalizePromptPath(value: string): string { + return value.replace(/\\/g, "/"); +} + +function renderActiveRepoContextPrompt(activeRepoContext: ActiveRepoContext | null): string { + if (!activeRepoContext) return ""; + return prompt + .render(activeRepoContextTemplate, { + relativeRepoRoot: normalizePromptPath(activeRepoContext.relativeRepoRoot), + }) + .trim(); +} + function parseWmicTable(output: string, header: string): string | null { const lines = output .split("\n") @@ -421,6 +436,8 @@ export interface BuildSystemPromptOptions { model?: string; /** Personality preset rendered into the default system prompt. "none" omits the block. Default: "default" */ personality?: Personality; + /** Pre-resolved nested active repo context. Undefined resolves from cwd. */ + activeRepoContext?: ActiveRepoContext | null; } /** Result of building provider-facing system prompt messages. */ @@ -461,6 +478,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): memoryRootEnabled = false, model, personality = "default", + activeRepoContext: providedActiveRepoContext, } = options; const inlineToolDescriptors = providedInlineToolDescriptors ?? false; const resolvedCwd = cwd ?? getProjectDir(); @@ -478,6 +496,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): totalLines: 0, agentsMdFiles: [], } satisfies WorkspaceTree, + activeRepoContext: null as ActiveRepoContext | null, }; const deadline = Bun.sleep(SYSTEM_PROMPT_PREP_TIMEOUT_MS).then(() => "__timeout__" as const); @@ -532,34 +551,42 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): : skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills) : Promise.resolve([]); + const activeRepoContextPromise = + providedActiveRepoContext !== undefined + ? Promise.resolve(providedActiveRepoContext) + : logger.time("resolveActiveRepoContext", () => resolveActiveRepoContext(resolvedCwd)); - const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles, skills, workspaceTree] = - await Promise.all([ - withDeadline( - "customPrompt", - providedResolvedCustomPrompt !== undefined - ? Promise.resolve(providedResolvedCustomPrompt) - : resolvePromptInput(customPrompt, "system prompt"), - prepDefaults.resolvedCustomPrompt, - ), - withDeadline( - "appendSystemPrompt", - providedResolvedAppendPrompt !== undefined - ? Promise.resolve(providedResolvedAppendPrompt) - : resolvePromptInput(appendSystemPrompt, "append system prompt"), - prepDefaults.resolvedAppendPrompt, - ), - withDeadline( - "loadSystemPromptFiles", - systemPromptCustomizationPromise, - prepDefaults.systemPromptCustomization, - ), - withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then( - dedupeExactContextFiles, - ), - withDeadline("loadSkills", skillsPromise, prepDefaults.skills), - withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree), - ]); + const [ + resolvedCustomPrompt, + resolvedAppendPrompt, + systemPromptCustomization, + contextFiles, + skills, + workspaceTree, + activeRepoContext, + ] = await Promise.all([ + withDeadline( + "customPrompt", + providedResolvedCustomPrompt !== undefined + ? Promise.resolve(providedResolvedCustomPrompt) + : resolvePromptInput(customPrompt, "system prompt"), + prepDefaults.resolvedCustomPrompt, + ), + withDeadline( + "appendSystemPrompt", + providedResolvedAppendPrompt !== undefined + ? Promise.resolve(providedResolvedAppendPrompt) + : resolvePromptInput(appendSystemPrompt, "append system prompt"), + prepDefaults.resolvedAppendPrompt, + ), + withDeadline("loadSystemPromptFiles", systemPromptCustomizationPromise, prepDefaults.systemPromptCustomization), + withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then( + dedupeExactContextFiles, + ), + withDeadline("loadSkills", skillsPromise, prepDefaults.skills), + withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree), + withDeadline("resolveActiveRepoContext", activeRepoContextPromise, prepDefaults.activeRepoContext), + ]); const agentsMdFiles = Array.from(new Set(workspaceTree.agentsMdFiles)).sort().slice(0, AGENTS_MD_LIMIT); if (timedOut.length > 0) { @@ -584,7 +611,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): const date = new Date().toISOString().slice(0, 10); const dateTime = date; - const promptCwd = shortenPath(resolvedCwd.replace(/\\/g, "/")); + const promptCwd = shortenPath(normalizePromptPath(resolvedCwd)); + const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext); // Build tool metadata for system prompt rendering. // Priority: explicit list > tools map > conservative SDK fallback. @@ -681,6 +709,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): if (projectPrompt) { systemPrompt.push(projectPrompt); } + if (activeRepoContextPrompt) { + systemPrompt.push(activeRepoContextPrompt); + } return { systemPrompt }; } diff --git a/packages/coding-agent/src/utils/active-repo-context.ts b/packages/coding-agent/src/utils/active-repo-context.ts new file mode 100644 index 0000000000..41226281b0 --- /dev/null +++ b/packages/coding-agent/src/utils/active-repo-context.ts @@ -0,0 +1,127 @@ +import * as fs from "node:fs"; +import * as fsPromises from "node:fs/promises"; +import * as path from "node:path"; + +import { type GitRepository, repo } from "./git"; + +export interface ActiveRepoContext { + cwd: string; + repoRoot: string; + relativeRepoRoot: string; + source: "single-direct-child-repo"; +} + +function compareEntryNames(left: fs.Dirent, right: fs.Dirent): number { + if (left.name < right.name) return -1; + if (left.name > right.name) return 1; + return 0; +} + +function buildContext(cwd: string, repoRoot: string): ActiveRepoContext { + const resolvedCwd = path.resolve(cwd); + const resolvedRepoRoot = path.resolve(repoRoot); + return { + cwd: resolvedCwd, + repoRoot: resolvedRepoRoot, + relativeRepoRoot: path.relative(resolvedCwd, resolvedRepoRoot), + source: "single-direct-child-repo", + }; +} + +async function resolveRepository(cwd: string): Promise { + try { + return await repo.resolve(cwd); + } catch { + return null; + } +} + +function resolveRepositorySync(cwd: string): GitRepository | null { + try { + return repo.resolveSync(cwd); + } catch { + return null; + } +} + +async function readDirectChildren(cwd: string): Promise { + try { + const entries = await fsPromises.readdir(cwd, { withFileTypes: true }); + entries.sort(compareEntryNames); + return entries; + } catch { + return []; + } +} + +function readDirectChildrenSync(cwd: string): fs.Dirent[] { + try { + const entries = fs.readdirSync(cwd, { withFileTypes: true }); + entries.sort(compareEntryNames); + return entries; + } catch { + return []; + } +} + +async function resolveDirectChildDirectory(cwd: string, entry: fs.Dirent): Promise { + const childPath = path.join(cwd, entry.name); + if (entry.isDirectory()) return childPath; + if (!entry.isSymbolicLink()) return null; + try { + const stat = await fsPromises.stat(childPath); + return stat.isDirectory() ? childPath : null; + } catch { + return null; + } +} + +function resolveDirectChildDirectorySync(cwd: string, entry: fs.Dirent): string | null { + const childPath = path.join(cwd, entry.name); + if (entry.isDirectory()) return childPath; + if (!entry.isSymbolicLink()) return null; + try { + const stat = fs.statSync(childPath); + return stat.isDirectory() ? childPath : null; + } catch { + return null; + } +} + +async function findSingleDirectChildRepo(cwd: string): Promise { + let context: ActiveRepoContext | null = null; + for (const entry of await readDirectChildren(cwd)) { + const childPath = await resolveDirectChildDirectory(cwd, entry); + if (!childPath) continue; + const repository = await resolveRepository(childPath); + if (!repository || path.resolve(repository.repoRoot) !== path.resolve(childPath)) continue; + if (context) return null; + context = buildContext(cwd, repository.repoRoot); + } + return context; +} + +function findSingleDirectChildRepoSync(cwd: string): ActiveRepoContext | null { + let context: ActiveRepoContext | null = null; + for (const entry of readDirectChildrenSync(cwd)) { + const childPath = resolveDirectChildDirectorySync(cwd, entry); + if (!childPath) continue; + const repository = resolveRepositorySync(childPath); + if (!repository || path.resolve(repository.repoRoot) !== path.resolve(childPath)) continue; + if (context) return null; + context = buildContext(cwd, repository.repoRoot); + } + return context; +} + +export async function resolveActiveRepoContext(cwd: string): Promise { + const resolvedCwd = path.resolve(cwd); + if (await resolveRepository(resolvedCwd)) return null; + return findSingleDirectChildRepo(resolvedCwd); +} + +export function resolveActiveRepoContextSync(cwd: string): ActiveRepoContext | null { + const resolvedCwd = path.resolve(cwd); + if (resolveRepositorySync(resolvedCwd)) return null; + return findSingleDirectChildRepoSync(resolvedCwd); +} diff --git a/packages/coding-agent/test/advisor-watchdog.test.ts b/packages/coding-agent/test/advisor-watchdog.test.ts index 5e1e0291bc..eb10b35f99 100644 --- a/packages/coding-agent/test/advisor-watchdog.test.ts +++ b/packages/coding-agent/test/advisor-watchdog.test.ts @@ -20,6 +20,62 @@ describe("advisor watchdog prompt discovery", () => { } }); + async function withAdvisorHistory( + tempDir: TempDir, + cwd: string, + run: (dump: string) => void | Promise, + ): Promise { + const authStorage = await AuthStorage.create(tempDir.join("testauth.db")); + let session: AgentSession | undefined; + try { + authStorage.setRuntimeApiKey("openai", "test-key"); + const modelRegistry = new ModelRegistry(authStorage); + const sessionManager = SessionManager.create(cwd, tempDir.join("sessions")); + const result = await createAgentSession({ + cwd, + agentDir: tempDir.path(), + sessionManager, + authStorage, + modelRegistry, + settings: (() => { + const s = Settings.isolated({ + "async.enabled": false, + "advisor.enabled": true, + }); + s.setModelRole("advisor", "openai/gpt-4o-mini"); + return s; + })(), + model: getBundledModel("openai", "gpt-4o-mini"), + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + workspaceTree: { + rootPath: cwd, + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], + }, + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + }); + session = result.session; + + expect(session.isAdvisorActive()).toBe(true); + const dump = session.formatAdvisorHistoryAsText(); + if (dump === null) throw new Error("Advisor history was not available."); + await run(dump); + } finally { + try { + await session?.dispose(); + } finally { + authStorage.close(); + } + } + } + it("discovers and appends WATCHDOG.md to the advisor prompt", async () => { const tempDir = TempDir.createSync("@pi-advisor-watchdog-"); tempDirs.push(tempDir); @@ -77,6 +133,39 @@ describe("advisor watchdog prompt discovery", () => { } }); + it("adds built-in active child repo context to the advisor prompt", async () => { + const tempDir = TempDir.createSync("@pi-advisor-watchdog-"); + tempDirs.push(tempDir); + const cwd = tempDir.join("parent-cwd"); + fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true }); + const watchdogContent = "Parent watchdog remains before built-in active repo context."; + fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8"); + + await withAdvisorHistory(tempDir, cwd, dump => { + expect(dump).toContain("Especially pay attention to:"); + expect(dump).toContain("exactly one direct child git repository"); + expect(dump).toContain("`active-project`"); + expect(dump).toContain("Do not claim work is missing, destroyed, or absent at the parent cwd"); + expect(dump).toContain(watchdogContent); + expect(dump.indexOf(watchdogContent)).toBeLessThan( + dump.indexOf("Do not claim work is missing, destroyed, or absent at the parent cwd"), + ); + }); + }); + + it("omits built-in active child repo context when multiple direct child repos exist", async () => { + const tempDir = TempDir.createSync("@pi-advisor-watchdog-"); + tempDirs.push(tempDir); + const cwd = tempDir.join("parent-cwd"); + fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true }); + fs.mkdirSync(path.join(cwd, "second-project", ".git"), { recursive: true }); + + await withAdvisorHistory(tempDir, cwd, dump => { + expect(dump).not.toContain("exactly one direct child git repository"); + expect(dump).not.toContain("Do not claim work is missing, destroyed, or absent at the parent cwd"); + }); + }); + it("resolves nested folders and sorts by depth", async () => { const tempDir = TempDir.createSync("@pi-advisor-watchdog-"); tempDirs.push(tempDir); diff --git a/packages/coding-agent/test/git-active-context.test.ts b/packages/coding-agent/test/git-active-context.test.ts new file mode 100644 index 0000000000..4a5c9b06fb --- /dev/null +++ b/packages/coding-agent/test/git-active-context.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + type ActiveRepoContext, + resolveActiveRepoContext, + resolveActiveRepoContextSync, +} from "@oh-my-pi/pi-coding-agent/utils/active-repo-context"; + +function createGitDirectory(repoRoot: string): void { + const gitDir = path.join(repoRoot, ".git"); + fs.mkdirSync(gitDir, { recursive: true }); + fs.writeFileSync(path.join(gitDir, "HEAD"), "ref: refs/heads/main\n", "utf8"); +} + +function createLinkedWorktreeGitFile(worktreeRoot: string, gitDir: string, commonDir: string): void { + fs.mkdirSync(worktreeRoot, { recursive: true }); + fs.mkdirSync(gitDir, { recursive: true }); + fs.mkdirSync(commonDir, { recursive: true }); + fs.writeFileSync(path.join(gitDir, "HEAD"), "ref: refs/heads/main\n", "utf8"); + fs.writeFileSync(path.join(gitDir, "commondir"), `${path.relative(gitDir, commonDir)}\n`, "utf8"); + fs.writeFileSync(path.join(commonDir, "HEAD"), "ref: refs/heads/main\n", "utf8"); + fs.writeFileSync(path.join(worktreeRoot, ".git"), `gitdir: ${path.relative(worktreeRoot, gitDir)}\n`, "utf8"); +} + +async function expectResolvers(cwd: string, expected: ActiveRepoContext | null): Promise { + expect(resolveActiveRepoContextSync(cwd)).toEqual(expected); + expect(await resolveActiveRepoContext(cwd)).toEqual(expected); +} + +describe("resolveActiveRepoContext", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omp-active-repo-context-")); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it("returns null when cwd is already inside a repository", async () => { + const repoRoot = path.join(tempRoot, "repo"); + const cwd = path.join(repoRoot, "nested"); + fs.mkdirSync(cwd, { recursive: true }); + createGitDirectory(repoRoot); + + await expectResolvers(cwd, null); + }); + + it("returns null when no direct child repository exists", async () => { + const cwd = path.join(tempRoot, "workspace"); + fs.mkdirSync(path.join(cwd, "not-a-repo"), { recursive: true }); + fs.writeFileSync(path.join(cwd, "plain-file.txt"), "ignored\n", "utf8"); + + await expectResolvers(cwd, null); + }); + + it("returns the sole direct child repository context", async () => { + const cwd = path.join(tempRoot, "workspace"); + const repoRoot = path.join(cwd, "repo"); + fs.mkdirSync(path.join(cwd, "not-a-repo"), { recursive: true }); + fs.writeFileSync(path.join(cwd, "plain-file.txt"), "ignored\n", "utf8"); + createGitDirectory(repoRoot); + + const expected = { + cwd, + repoRoot, + relativeRepoRoot: "repo", + source: "single-direct-child-repo", + } satisfies ActiveRepoContext; + await expectResolvers(cwd, expected); + }); + + it("treats a direct child symlink to a repository directory as that child", async () => { + const cwd = path.join(tempRoot, "workspace"); + const targetRoot = path.join(tempRoot, "target-repo"); + const repoRoot = path.join(cwd, "linked-repo"); + fs.mkdirSync(cwd, { recursive: true }); + createGitDirectory(targetRoot); + fs.symlinkSync(targetRoot, repoRoot, "junction"); + + const expected = { + cwd, + repoRoot, + relativeRepoRoot: "linked-repo", + source: "single-direct-child-repo", + } satisfies ActiveRepoContext; + await expectResolvers(cwd, expected); + }); + + it("returns null when two direct child repositories exist", async () => { + const cwd = path.join(tempRoot, "workspace"); + fs.mkdirSync(cwd, { recursive: true }); + createGitDirectory(path.join(cwd, "alpha")); + createGitDirectory(path.join(cwd, "beta")); + + await expectResolvers(cwd, null); + }); + + it("accepts a direct child linked-worktree .git file", async () => { + const cwd = path.join(tempRoot, "workspace"); + const repoRoot = path.join(cwd, "worktree"); + const gitDir = path.join(tempRoot, "admin", "worktrees", "worktree"); + const commonDir = path.join(tempRoot, "admin", "common.git"); + fs.mkdirSync(cwd, { recursive: true }); + createLinkedWorktreeGitFile(repoRoot, gitDir, commonDir); + + const expected = { + cwd, + repoRoot, + relativeRepoRoot: "worktree", + source: "single-direct-child-repo", + } satisfies ActiveRepoContext; + await expectResolvers(cwd, expected); + }); +}); diff --git a/packages/coding-agent/test/issue-953-repro.test.ts b/packages/coding-agent/test/issue-953-repro.test.ts index 8f3901f93d..54b0d3f90c 100644 --- a/packages/coding-agent/test/issue-953-repro.test.ts +++ b/packages/coding-agent/test/issue-953-repro.test.ts @@ -36,6 +36,7 @@ function createCtx(usage: Partial): SegmentContext autoCompactEnabled: false, subagentCount: 0, sessionStartTime: Date.now(), + activeRepo: null, git: { branch: null, status: null, diff --git a/packages/coding-agent/test/status-line-model.test.ts b/packages/coding-agent/test/status-line-model.test.ts index c4f7c50be2..3a9d5fb00a 100644 --- a/packages/coding-agent/test/status-line-model.test.ts +++ b/packages/coding-agent/test/status-line-model.test.ts @@ -36,6 +36,7 @@ function createModelContext(advisorActive: boolean): SegmentContext { autoCompactEnabled: false, subagentCount: 0, sessionStartTime: Date.now(), + activeRepo: null, git: { branch: null, status: null, pr: null }, usage: null, }; diff --git a/packages/coding-agent/test/status-line-overflow.test.ts b/packages/coding-agent/test/status-line-overflow.test.ts index f4fe3a1e16..87a52f95ee 100644 --- a/packages/coding-agent/test/status-line-overflow.test.ts +++ b/packages/coding-agent/test/status-line-overflow.test.ts @@ -60,6 +60,7 @@ function createCtx(overrides?: { pathMaxLength?: number; branch?: string | null autoCompactEnabled: false, subagentCount: 0, sessionStartTime: Date.now(), + activeRepo: null, git: { branch: overrides?.branch ?? null, status: null, diff --git a/packages/coding-agent/test/status-line-path.test.ts b/packages/coding-agent/test/status-line-path.test.ts index 65c9ffac36..931ad61834 100644 --- a/packages/coding-agent/test/status-line-path.test.ts +++ b/packages/coding-agent/test/status-line-path.test.ts @@ -46,6 +46,7 @@ function createPathContext(): SegmentContext { autoCompactEnabled: false, subagentCount: 0, sessionStartTime: Date.now(), + activeRepo: null, git: { branch: null, status: null, @@ -59,6 +60,14 @@ afterEach(() => { setProjectDir(originalProjectDir); }); +function expectContentToContainPath(content: string, expected: string): void { + if (process.platform === "win32") { + expect(content.toLowerCase()).toContain(expected.toLowerCase()); + return; + } + expect(content).toContain(expected); +} + describe("status line path segment", () => { it("strips the Projects root for symlink-equivalent aliases", () => { if (process.platform === "win32") return; @@ -86,6 +95,7 @@ describe("status line path segment", () => { expect(rendered.content).not.toContain("home-link"); expect(rendered.content).not.toContain(`${path.sep}Projects${path.sep}`); } finally { + setProjectDir(originalProjectDir); fs.rmSync(aliasRoot, { recursive: true, force: true }); fs.rmSync(realProjectDir, { recursive: true, force: true }); } @@ -101,9 +111,10 @@ describe("status line path segment", () => { expect(rendered.content).toContain(theme.icon.scratchFolder); expect(rendered.content).not.toContain(theme.icon.folder); // Display is just the scratch-relative tail — no leading tmpdir, no ancestor segments. - expect(rendered.content).toContain(path.basename(scratchDir)); + expectContentToContainPath(rendered.content, path.basename(getProjectDir())); expect(rendered.content).not.toContain(os.tmpdir()); } finally { + setProjectDir(originalProjectDir); fs.rmSync(scratchDir, { recursive: true, force: true }); } }); @@ -116,11 +127,12 @@ describe("status line path segment", () => { setProjectDir(nested); const rendered = renderSegment("path", createPathContext()); - const tail = `${path.basename(scratchDir)}${path.sep}sub${path.sep}deep`; + const tail = `${path.basename(path.dirname(path.dirname(getProjectDir())))}${path.sep}sub${path.sep}deep`; expect(rendered.content).toContain(theme.icon.scratchFolder); - expect(rendered.content).toContain(tail); + expectContentToContainPath(rendered.content, tail); expect(rendered.content).not.toContain(os.tmpdir()); } finally { + setProjectDir(originalProjectDir); fs.rmSync(scratchDir, { recursive: true, force: true }); } }); @@ -137,6 +149,7 @@ describe("status line path segment", () => { expect(rendered.content).toContain(theme.icon.folder); expect(rendered.content).not.toContain(theme.icon.scratchFolder); } finally { + setProjectDir(originalProjectDir); fs.rmSync(scratchDir, { recursive: true, force: true }); } }); @@ -153,7 +166,33 @@ describe("status line path segment", () => { expect(rendered.content).toContain(theme.icon.folder); expect(rendered.content).not.toContain(theme.icon.scratchFolder); } finally { + setProjectDir(originalProjectDir); fs.rmSync(realProjectDir, { recursive: true, force: true }); } }); + + it("renders the active nested repo suffix after the parent cwd", () => { + const parentDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-status-line-parent-")); + const repoDir = path.join(parentDir, "pr-workspace"); + fs.mkdirSync(repoDir); + try { + setProjectDir(parentDir); + const ctx = createPathContext(); + ctx.activeRepo = { + cwd: parentDir, + repoRoot: repoDir, + relativeRepoRoot: "pr-workspace", + source: "single-direct-child-repo", + }; + + const rendered = renderSegment("path", ctx); + const expected = `${path.basename(getProjectDir())} ↳ pr-workspace`; + expect(rendered.visible).toBe(true); + expectContentToContainPath(rendered.content, expected); + expect(rendered.content).not.toContain(os.tmpdir()); + } finally { + setProjectDir(originalProjectDir); + fs.rmSync(parentDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/coding-agent/test/system-prompt-dedup.test.ts b/packages/coding-agent/test/system-prompt-dedup.test.ts index 24faed5593..968adff7d4 100644 --- a/packages/coding-agent/test/system-prompt-dedup.test.ts +++ b/packages/coding-agent/test/system-prompt-dedup.test.ts @@ -131,16 +131,47 @@ describe("SYSTEM.md prompt assembly", () => { }); const promptText = systemPrompt.join("\n\n"); + const normalizedProjectDir = projectDir.replace(/\\/g, "/"); const appendMatches = promptText.match(new RegExp(escapeRegExp(appendPrompt), "g")) ?? []; expect(systemPrompt).toHaveLength(2); expect(promptText).toContain("CLI custom prompt"); expect(promptText).toContain(""); expect(promptText).toContain(""); - expect(promptText).toContain(`current working directory is '${projectDir}'`); + expect(promptText).toMatch( + new RegExp( + `^Today is [^,\\n]+, and the current working directory is '${escapeRegExp(normalizedProjectDir)}'\\.$`, + "m", + ), + ); expect(appendMatches).toHaveLength(1); expect(promptText).not.toContain("Discovered project SYSTEM prompt"); }); + it("renders active child repo context in the main system prompt", async () => { + const parentDir = path.join(tempDir, "parent-cwd"); + fs.mkdirSync(path.join(parentDir, "active-project", ".git"), { recursive: true }); + + const { systemPrompt } = await buildSystemPrompt({ + cwd: parentDir, + contextFiles: [], + skills: [], + rules: [], + toolNames: [], + workspaceTree: { + rootPath: parentDir, + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], + }, + }); + + const promptText = systemPrompt.join("\n\n"); + expect(promptText).toContain(""); + expect(promptText).toContain("Exactly one direct child git repository was detected at `active-project`."); + expect(promptText).toContain("Paths under `active-project/` are the active project"); + }); + it("prefers project SYSTEM.md over user SYSTEM.md", async () => { const projectDir = path.join(tempDir, "project"); fs.mkdirSync(path.join(projectDir, ".omp"), { recursive: true });