From f83974f8bbbd85fc192fd74bf809c89d1be2c4f0 Mon Sep 17 00:00:00 2001 From: j-token Date: Mon, 10 Aug 2026 11:58:37 +0900 Subject: [PATCH 1/2] feat(config): scope harness state, RLM settings, and context files per repository Adds a project config scope alongside the global one so per-repository settings and per-user settings stop mixing: - Harness state gains a project store in /.prime/agent/harness/, merged as global < project < local and selectable with /refine --project, refine.run(scope="project"), and rlm.harness.*(scope="project"). Project refinements are recorded next to their store so they can be rolled back from a later session in the same repository. - rlmMaxDepth is read from project settings before global settings, and /rlm-max-depth accepts --project alongside --global. - contextFiles settings can disable AGENTS.md/CLAUDE.md discovery, drop the global AGENTS.md, or stop the walk above the project root, with /settings toggles for the first two. - Project settings, SYSTEM.md, and APPEND_SYSTEM.md resolve against the project root (nearest .prime/agent directory, else the repository root), so running from a subdirectory uses the repository's configuration. The home directory is never treated as a project. The harness Python API takes scope="local"|"project"|"global" instead of global_=True; the bootstrap schema is bumped so existing kernel venvs rebuild. Daemon: adds the config_scopes server capability and schema revision 15. Clients fall back to the legacy boolean flag on older daemons and fail loudly when project scope is requested. --- README.md | 4 +- packages/coding-agent/CHANGELOG.md | 5 + packages/coding-agent/docs/rlm-runtime.md | 19 +- packages/coding-agent/docs/settings.md | 43 +++- packages/coding-agent/docs/usage.md | 16 +- packages/coding-agent/skills/refine/SKILL.md | 10 +- .../skills/refine/src/refine/__init__.py | 18 +- packages/coding-agent/src/config.ts | 53 +++++ .../coding-agent/src/core/agent-session.ts | 210 +++++++++++------- .../coding-agent/src/core/extensions/types.ts | 5 +- .../coding-agent/src/core/kernel/bootstrap.ts | 4 +- packages/coding-agent/src/core/prompts/rlm.ts | 2 +- .../src/core/refinement/refinement.ts | 117 ++++++---- .../coding-agent/src/core/resource-loader.ts | 57 +++-- .../coding-agent/src/core/rlm-max-depth.ts | 10 +- .../coding-agent/src/core/settings-manager.ts | 50 ++++- .../coding-agent/src/core/slash-commands.ts | 41 ++-- .../daemon-agent-connection.ts | 48 ++-- .../in-process-agent-connection.ts | 7 +- .../src/modes/agent-connection/types.ts | 7 +- .../src/modes/daemon/daemon-mode.ts | 8 +- .../src/modes/daemon/daemon-protocol.ts | 26 ++- .../components/settings-selector.ts | 35 ++- .../src/modes/interactive/interactive-mode.ts | 41 ++-- .../coding-agent/src/modes/rpc/rpc-client.ts | 10 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 2 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 4 +- .../test/agent-connection-daemon.test.ts | 40 ++++ .../test/agent-session-recursion.test.ts | 6 +- packages/coding-agent/test/config.test.ts | 46 ++++ .../coding-agent/test/daemon-mode.test.ts | 12 +- .../test/kernel-bootstrap.test.ts | 6 +- packages/coding-agent/test/refinement.test.ts | 68 ++++-- .../coding-agent/test/resource-loader.test.ts | 68 ++++++ .../test/settings-manager.test.ts | 54 +++++ .../test/settings-selector.test.ts | 4 + .../coding-agent/test/slash-commands.test.ts | 29 ++- .../test/suite/acp-features.test.ts | 2 +- .../test/suite/agent-session-queue.test.ts | 74 +++++- prime-agent-runtime/src/rlm/__init__.py | 2 +- prime-agent-runtime/src/rlm/harness.py | 194 ++++++++-------- prime-agent-runtime/test/test_harness.py | 119 +++++++--- 42 files changed, 1169 insertions(+), 407 deletions(-) diff --git a/README.md b/README.md index 1d6f850c5..979d2b385 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,13 @@ Prime Agent: A Self-Improving RLM Agent Prime Agent is an open-source coding and research agent for general and long-running work. It is designed around two core abstractions: - The **[Recursive Language Model (RLM)](https://www.primeintellect.ai/blog/rlm)** treats context as variables (*prompt-as-a-variable*) and tools like recursive subagents as function calls (*programmatic tool /sub-agent calling*) inside a persistent REPL. -- The **[Continual Harness](https://arxiv.org/abs/2605.09998)** stores supplemental prompts, memories, skill descriptions, and reusable subagent specifications as durable state that Prime Agent can refine through small, evidence-backed updates, local to the session by default. +- The **[Continual Harness](https://arxiv.org/abs/2605.09998)** stores supplemental prompts, memories, skill descriptions, and reusable subagent specifications as durable state that Prime Agent can refine through small, evidence-backed updates, scoped to the session, the repository, or all projects. Prime Agent combines a persistent Python control environment with durable harness state, so useful working context and reusable operating patterns can outlive a single chat window. - **Everything is programmatic:** persistent IPython is the built-in model tool; file operations, shell commands, tool use, subagents, and context management happen through code. - **Subagents are built in:** `rlm(...)` spawns real child agents for parallel or background work and returns their results programmatically. -- **The harness can improve:** `/refine` reviews the current trajectory and can apply small, evidence-backed updates to supplemental harness state. It never rewrites the immutable base system prompt, and recorded snapshots support rollback. +- **The harness can improve:** `/refine` reviews the current trajectory and can apply small, evidence-backed updates to supplemental harness state, in this session, this repository (`--project`), or every project (`--global`). It never rewrites the immutable base system prompt, and recorded snapshots support rollback. - **Skills are executable:** skills are importable Python packages, and the built-in skill creator can turn recurring workflows into project or personal skills. - **Sessions run in the background:** daemon-backed agents keep running when the terminal disconnects and can be reattached later. - **Agents communicate directly:** running agents can exchange messages and orchestrate one another without routing everything through the user. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f7100b579..866c9141a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,11 @@ - Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)). - Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary. - Fixed Homebrew installs attempting to self-update their versioned Cellar keg instead of directing users to `brew upgrade prime-agent` ([#844](https://github.com/PrimeIntellect-ai/prime-agent/issues/844)) +- Added a project harness scope stored in `/.prime/agent/harness/`, selectable with `/refine --project`, `await refine.run(scope="project")`, and `rlm.harness.*(scope="project")`. +- Added `contextFiles` settings to disable `AGENTS.md`/`CLAUDE.md` discovery, drop the global `AGENTS.md`, or stop walking above the project root, with `/settings` toggles for the first two. +- Changed `rlmMaxDepth` to be read from project settings before global settings, and `/rlm-max-depth` to accept `--project` alongside `--global`. +- Changed project settings, `SYSTEM.md`, and `APPEND_SYSTEM.md` to resolve against the project root, so running Prime Agent from a subdirectory uses the repository's configuration. +- Changed the harness Python API from `global_=True` to `scope="local"|"project"|"global"` on `rlm.harness.*`, `rlm.get_harness_state()`, and `refine.run()`. ## [0.7.1] - 2026-08-07 diff --git a/packages/coding-agent/docs/rlm-runtime.md b/packages/coding-agent/docs/rlm-runtime.md index 4de095cf0..d85ada91b 100644 --- a/packages/coding-agent/docs/rlm-runtime.md +++ b/packages/coding-agent/docs/rlm-runtime.md @@ -208,9 +208,24 @@ On reload, the aggregate is reapplied to the parent message. Context-tree report `rlm.harness` is a persisted state ledger for prompt notes, memories, reusable skill descriptions, sub-agent specifications, and refinement events. It is not a second execution engine. -Session-local state lives in the session artifact directory under `harness/harness_state.json`. Explicitly global entries live under `~/.prime/agent/harness/`. The Python store reloads after external modification so host-side `/refine` writes and kernel writes do not overwrite each other. +State is stored in three scopes: -`/refine` runs a dedicated review over the current trajectory and applies small create/update/delete edits. Rollback uses recorded before/after snapshots. The base system prompt remains immutable; refinements are supplemental state. +| Scope | Location | Use for | +|---|---|---| +| `local` (default) | session artifact dir, `harness/harness_state.json` | current task progress, temporary blockers, session coordination | +| `project` | `/.prime/agent/harness/harness_state.json` | repository conventions, build/test commands, recurring pitfalls | +| `global` | `~/.prime/agent/harness/harness_state.json` | cross-project lessons, durable user preferences, reusable skills and subagent specs | + +The system prompt shows all three merged, with narrower scopes winning id collisions and keeping a `:` display key. Writes target one scope: + +```python +rlm.harness.create_memory("Test command", "npm run check", scope="project") +await refine.run("record how this repository runs its tests", scope="project") +``` + +The kernel resolves scopes from `RLM_HARNESS_STATE_DIR`/`RLM_SESSION_DIR` (local), `RLM_PROJECT_HARNESS_STATE_DIR` (project), and `RLM_GLOBAL_HARNESS_STATE_DIR` (global). The Python store reloads after external modification so host-side `/refine` writes and kernel writes do not overwrite each other. + +`/refine` runs a dedicated review over the current trajectory and applies small create/update/delete edits. `/refine --project` and `/refine --global` select the target store. Rollback uses recorded before/after snapshots; project and global refinements are also recorded in `refinements.jsonl` next to their store so they can be rolled back from a later session. The base system prompt remains immutable; refinements are supplemental state. ## Goal Requests diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index fcfcef9ae..f479282c9 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -5,7 +5,13 @@ Prime Agent uses JSON settings files with project settings overriding global set | Location | Scope | |----------|-------| | `~/.prime/agent/settings.json` | Global (all projects) | -| `.prime/agent/settings.json` | Project (current directory) | +| `/.prime/agent/settings.json` | Project (this repository) | + +The project directory is the nearest ancestor of the working directory that already has a +`.prime/agent` directory, otherwise the enclosing repository root, otherwise the working +directory itself. Starting Prime Agent in a subdirectory therefore uses the same project +settings, harness state, and `SYSTEM.md` as starting it at the repository root. The home +directory is never treated as a project. Edit directly or use `/settings` for common options. @@ -216,6 +222,41 @@ Normally the package manager's global modules location is queried using `root -g When multiple sources specify a session directory, precedence is `--session-dir`, `PRIME_AGENT_SESSION_DIR`, the legacy `PRIME_AGENT_CODING_AGENT_SESSION_DIR`, then `sessionDir` in `settings.json`. +### Context Files + +`AGENTS.md` and `CLAUDE.md` are loaded into the system prompt. Set these globally to change the +default, or per project to keep unrelated instructions out of one repository. + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `contextFiles.enabled` | boolean | `true` | Load `AGENTS.md`/`CLAUDE.md` at all | +| `contextFiles.global` | boolean | `true` | Include `~/.prime/agent/AGENTS.md` in every project | +| `contextFiles.ancestors` | boolean | `true` | Include directories above the project root | + +Keep only this repository's own instructions: + +```json +{ + "contextFiles": { + "global": false, + "ancestors": false + } +} +``` + +`--no-context-files` disables discovery for a single run regardless of these settings. +The `/settings` menu exposes the first two toggles as "AGENTS.md context" and "Global AGENTS.md". + +### Recursive Agents + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `rlmMaxDepth` | number | `1` | Default recursion depth for new sessions; `0` disables subagents | + +`rlmMaxDepth` is read from project settings first, then global settings, then `RLM_MAX_DEPTH`. +`/rlm-max-depth ` changes the current chat only; `/rlm-max-depth --project` and +`/rlm-max-depth --global` also persist the default to that settings file. + ### Model Cycling | Setting | Type | Default | Description | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 1f9291680..01861bb68 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -52,7 +52,7 @@ Type `/` in the editor to open command completion. Extensions can register custo | `/fork` | Create a new session from a previous user message | | `/clone` | Duplicate the current active branch into a new session | | `/compact [prompt]` | Manually compact context, optionally with custom instructions | -| `/refine [instructions]` | Refine or roll back session-backed harness state | +| `/refine [--project\|--global] [instructions]` | Refine or roll back session, project, or global harness state | | `/copy` | Copy last assistant message to clipboard | | `/btw `, `/side ` | Ask an inline side question without adding it to the session; replies continue the side conversation, esc returns | | `/export [file]` | Export session to HTML | @@ -137,17 +137,27 @@ Prime Agent loads `AGENTS.md` or `CLAUDE.md` at startup from: - parent directories, walking up from the current working directory - the current directory -Use context files for project conventions, commands, safety rules, and preferences. Disable loading with `--no-context-files` or `-nc`. +Use context files for project conventions, commands, safety rules, and preferences. Disable loading for one run with `--no-context-files` or `-nc`. + +To stop global or unrelated instructions from mixing into one repository, set `contextFiles` in +`/.prime/agent/settings.json` (see [Settings](settings.md#context-files)): + +```json +{ "contextFiles": { "global": false, "ancestors": false } } +``` ### System Prompt Files Replace the default system prompt with: -- `.prime/agent/SYSTEM.md` for a project +- `/.prime/agent/SYSTEM.md` for a project - `~/.prime/agent/SYSTEM.md` globally Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in either location. +Both files are resolved against the project root (the nearest `.prime/agent` directory or +enclosing repository root), so they apply from any subdirectory of the repository. + ## Exporting and Sharing Sessions Use `/export [file]` to write a session to HTML. diff --git a/packages/coding-agent/skills/refine/SKILL.md b/packages/coding-agent/skills/refine/SKILL.md index 3d5c83f4e..2c6eb0687 100644 --- a/packages/coding-agent/skills/refine/SKILL.md +++ b/packages/coding-agent/skills/refine/SKILL.md @@ -15,7 +15,8 @@ IPython: await refine.status() await refine.run() await refine.run("create a memory about always checking git status before committing") -await refine.run("promote the error-handling pattern to a global skill", global_=True) +await refine.run("record how this repository runs its tests", scope="project") +await refine.run("promote the error-handling pattern to a global skill", scope="global") ``` ## API @@ -23,11 +24,12 @@ await refine.run("promote the error-handling pattern to a global skill", global_ - `await refine.status()` — current refine state as a dict: `pending` (whether a requested refine is already queued for this turn) and `in_flight` (whether a refine is currently planning or applying). -- `await refine.run(instructions=None, global_=False)` — schedule refinement. +- `await refine.run(instructions=None, scope=None)` — schedule refinement. Returns `{"scheduled": True}` immediately, or `{"scheduled": False, "reason": ...}` when refinement cannot start. Optional `instructions` focus the refinement on a - specific observation. Set `global_=True` to target the global harness store - (cross-session); omit for local (session-scoped) refinement. + specific observation. `scope` selects the target store: `"local"` (this session, + the default), `"project"` (this repository, across sessions), or `"global"` + (every session and project). ## Rules diff --git a/packages/coding-agent/skills/refine/src/refine/__init__.py b/packages/coding-agent/skills/refine/src/refine/__init__.py index 5c50a37c5..74048e0c6 100644 --- a/packages/coding-agent/skills/refine/src/refine/__init__.py +++ b/packages/coding-agent/skills/refine/src/refine/__init__.py @@ -22,9 +22,12 @@ async def status() -> dict[str, Any]: return await host_request("refine.status") +SCOPES = ("local", "project", "global") + + async def run( instructions: str | None = None, - global_: bool = False, + scope: str | None = None, ) -> dict[str, Any]: """Schedule continual harness refinement. @@ -33,18 +36,19 @@ async def run( you automatically. Returns `{"scheduled": True}`, or `{"scheduled": False, "reason": ...}` when refinement cannot start. Optional `instructions` focus the refinement on a specific observation. - Set `global_=True` to target the global (cross-session) harness store; - omit for local (session-scoped) refinement. + `scope` selects the target store: "local" (this session, the default), + "project" (this repository, across sessions), or "global" (every session + and project). """ if instructions is not None and not isinstance(instructions, str): raise TypeError( f"instructions must be str or None, got {type(instructions).__name__}" ) - if not isinstance(global_, bool): - raise TypeError(f"global_ must be bool, got {type(global_).__name__}") + if scope is not None and scope not in SCOPES: + raise ValueError(f"scope must be one of {SCOPES}, got {scope!r}") payload: dict[str, Any] = {} if instructions is not None: payload["instructions"] = instructions - if global_: - payload["global"] = True + if scope is not None: + payload["scope"] = scope return await host_request("refine.run", payload) diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index b709ab10e..702bdc9ff 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -662,3 +662,56 @@ export function getSessionDirEnvOverride(): string | undefined { export function getDebugLogPath(): string { return join(getAgentDir(), `${APP_NAME}-debug.log`); } + +// ============================================================================= +// Project Config Paths (/.prime/agent/*) +// ============================================================================= + +/** + * Resolve the project root for a working directory: the nearest ancestor that + * already holds a project config dir, else the nearest repository root, else the + * working directory itself. Running the agent from a subdirectory therefore uses + * the same project settings and harness state as running it from the repo root. + */ +export function getProjectDir(cwd: string): string { + // The home directory is never a project root: its config dir is the user's own + // agent dir, and a dotfiles repository there would otherwise make every project + // share one store. + const homeDir = resolve(homedir()); + const resolvedCwd = resolve(cwd); + let currentDir = resolvedCwd; + let repoRoot: string | undefined; + let configRoot: string | undefined; + + while (true) { + if (currentDir !== homeDir) { + if (!configRoot && existsSync(join(currentDir, CONFIG_DIR_NAME))) { + configRoot = currentDir; + } + if (!repoRoot && existsSync(join(currentDir, ".git"))) { + repoRoot = currentDir; + } + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + + // A config dir above the repository root belongs to something else (an + // enclosing checkout or a home-like directory), so the repository wins. + if (repoRoot && (!configRoot || !isSameOrInside(configRoot, repoRoot))) { + return repoRoot; + } + return configRoot ?? resolvedCwd; +} + +function isSameOrInside(target: string, root: string): boolean { + if (target === root) return true; + const prefix = root.endsWith(sep) ? root : `${root}${sep}`; + return target.startsWith(prefix); +} + +/** Get the project config directory (e.g., /.prime/agent/) */ +export function getProjectConfigDir(cwd: string): string { + return join(getProjectDir(cwd), CONFIG_DIR_NAME); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e99462eab..473b66c31 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -194,22 +194,27 @@ import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js import { type AutoRefineReason, type AutoRefineReview, - appendGlobalRefinement, + appendSharedRefinement, applyRefinementProposal, getGlobalHarnessStateDir, getLocalHarnessStateDir, + getProjectHarnessStateDir, getRefinementHistory, + HARNESS_SCOPES, + type HarnessScope, type HarnessState, inferRefinementResultScope, - loadGlobalRefinementHistory, loadHarnessState, + loadSharedRefinementHistory, mergeHarnessStates, mergeRefinementHistory, planRefinement, REFINE_SKILL_NAME, type RefinementPlan, type RefinementResult, + type RefineOptions, reviewAutoRefine, + type ScopedHarnessStates, saveHarnessState, } from "./refinement/index.js"; import { resolveConfigValue } from "./resolve-config-value.js"; @@ -254,7 +259,7 @@ import { SessionManager, } from "./session-manager.js"; import type { SessionStats } from "./session-stats.js"; -import type { SettingsManager } from "./settings-manager.js"; +import type { SettingsManager, SettingsScope } from "./settings-manager.js"; import { getPythonSkillRuntimeInfo, type Skill } from "./skills.js"; import { parseRefineCommandOptions, @@ -520,7 +525,7 @@ export type SerializedBackgroundPlanResult = | { status: "plan"; plan: RefinementPlan; - options: { instructions?: string; rollbackId?: string; global?: boolean }; + options: RefineOptions; abort: AbortController; branchVersion: number; } @@ -531,7 +536,7 @@ export type SerializedBackgroundPlanResult = /** True when the background plan was for an explicit refine.run (skipReview). */ explicit: boolean; /** Original options for the failed plan, to allow re-queue on explicit failure. */ - options: { instructions?: string; rollbackId?: string; global?: boolean }; + options: RefineOptions; branchVersion: number; }; @@ -968,12 +973,24 @@ const RLM_MAX_DEPTH_STATE_CUSTOM_TYPE = "rlm_max_depth_state"; function noopRlmChildAbort(): void {} function noopRlmChildEventUnsubscribe(): void {} +/** Drop the display-only `:` prefix the harness overview renders for entry ids. */ +function stripHarnessScopePrefix(id: string | undefined): string | undefined { + if (!id) return id; + for (const scope of HARNESS_SCOPES) { + const prefix = `${scope}:`; + if (id.startsWith(prefix)) { + return id.slice(prefix.length); + } + } + return id; +} + function autoRefineInstructions(reason: AutoRefineReason, review: AutoRefineReview): string { const detail = review.instructions ? ` Reviewer instructions: ${review.instructions}` : ""; - return `Automatic refine review triggered by ${reason}. Only create/update/delete local harness entries if there is clear evidence that should help this session continue. Prefer an empty edits array over speculative or one-off memories. Do not promote anything global unless explicitly requested. Reviewer rationale: ${review.rationale}${detail}`; + return `Automatic refine review triggered by ${reason}. Only create/update/delete local harness entries if there is clear evidence that should help this session continue. Prefer an empty edits array over speculative or one-off memories. Do not promote anything to the project or global store unless explicitly requested. Reviewer rationale: ${review.rationale}${detail}`; } function isNonNegativeInteger(value: unknown): value is number { @@ -1136,7 +1153,7 @@ export class AgentSession { private _overflowRecovery: "idle" | "attempted" | "reported" = "idle"; private _continueAfterThresholdCompaction = false; private _pendingRequestedCompaction: { customInstructions?: string } | undefined; - private _pendingRequestedRefine: { instructions?: string; global?: boolean } | undefined; + private _pendingRequestedRefine: { instructions?: string; scope?: HarnessScope } | undefined; // Branch summarization state private _branchSummaryAbortController: AbortController | undefined = undefined; @@ -1279,7 +1296,7 @@ export class AgentSession { private _serializedPlanClaim?: Promise; private _serializedExplicitRefineOptions?: { instructions?: string; - global?: boolean; + scope?: HarnessScope; }; constructor(config: AgentSessionConfig) { @@ -1581,7 +1598,11 @@ export class AgentSession { if (this._configuredRlmMaxDepth !== undefined) { return { maxDepth: this._configuredRlmMaxDepth, source: "inherited" }; } - const global = this.settingsManager.getRlmMaxDepth(); + const project = this.settingsManager.getRlmMaxDepth("project"); + if (project !== undefined && isNonNegativeInteger(project)) { + return { maxDepth: project, source: "project" }; + } + const global = this.settingsManager.getRlmMaxDepth("global"); if (global !== undefined && isNonNegativeInteger(global)) { return { maxDepth: global, source: "global" }; } @@ -2536,7 +2557,7 @@ export class AgentSession { * plan ("plan") and apply that exact plan without re-planning. */ private async _runBackgroundPlan( - options: { instructions?: string; rollbackId?: string; global?: boolean }, + options: RefineOptions, refineAbort: AbortController, branchVersion: number, skipReview = false, @@ -2600,11 +2621,7 @@ export class AgentSession { * so the agent is between turns and _applyRefine's disconnect/reconnect * is safe. */ - private async _runSerializedRefine(options: { - instructions?: string; - rollbackId?: string; - global?: boolean; - }): Promise { + private async _runSerializedRefine(options: RefineOptions): Promise { if (this._disposed || this._disposing) { return; } @@ -2915,9 +2932,9 @@ export class AgentSession { if (instructions !== undefined && typeof instructions !== "string") { throw new Error("refine.run instructions must be a string when provided"); } - const globalFlag = payload.global; - if (globalFlag !== undefined && typeof globalFlag !== "boolean") { - throw new Error("refine.run global must be a boolean when provided"); + const scope = payload.scope; + if (scope !== undefined && !HARNESS_SCOPES.includes(scope as HarnessScope)) { + throw new Error(`refine.run scope must be one of ${HARNESS_SCOPES.join(", ")} when provided`); } if (!this.isStreaming) { return { @@ -2928,7 +2945,7 @@ export class AgentSession { const previous = this._pendingRequestedRefine ?? this._serializedExplicitRefineOptions; this._pendingRequestedRefine = { instructions: instructions ?? previous?.instructions, - global: globalFlag ?? previous?.global, + scope: (scope as HarnessScope | undefined) ?? previous?.scope, }; // In serialized mode, kick off background planning immediately // (the primary response ended at message_end, tools are active). @@ -7189,6 +7206,23 @@ export class AgentSession { ); } + /** Per-repository harness store for the session's working directory. */ + private _projectHarnessStateDir(): string { + return getProjectHarnessStateDir(this._cwd); + } + + /** Directory backing a scope, or undefined when the scope has no store in this session. */ + private _harnessStateDirForScope(scope: HarnessScope): string | undefined { + switch (scope) { + case "global": + return getGlobalHarnessStateDir(); + case "project": + return this._projectHarnessStateDir(); + default: + return this._localHarnessStateDir(); + } + } + private _autoRefineAllowedForSession(): boolean { return this._rlmDepth === 0 && this._localHarnessStateDir() !== undefined; } @@ -7564,18 +7598,38 @@ export class AgentSession { ); } - /** Global harness state overlaid with this session's local state, when persisted. */ - private _loadMergedHarnessState(): HarnessState { + private _loadScopedHarnessStates(): ScopedHarnessStates { const localHarnessStateDir = this._localHarnessStateDir(); - return mergeHarnessStates( - loadHarnessState(getGlobalHarnessStateDir(), "global"), - localHarnessStateDir ? loadHarnessState(localHarnessStateDir, "local") : undefined, - ); + return { + global: loadHarnessState(getGlobalHarnessStateDir(), "global"), + project: loadHarnessState(this._projectHarnessStateDir(), "project"), + local: localHarnessStateDir ? loadHarnessState(localHarnessStateDir, "local") : undefined, + }; + } + + /** Classify a harness directory recorded by an earlier refinement result. */ + private _scopeForHarnessStateDir(harnessStateDir: string): HarnessScope { + const resolvedDir = resolve(harnessStateDir); + if (resolvedDir === resolve(getGlobalHarnessStateDir())) { + return "global"; + } + if (resolvedDir === resolve(this._projectHarnessStateDir())) { + return "project"; + } + return "local"; + } + + /** Global harness state overlaid with the project store and this session's local state. */ + private _loadMergedHarnessState(): HarnessState { + return mergeHarnessStates(this._loadScopedHarnessStates()); } private _loadRefinementHistory(): RefinementResult[] { return mergeRefinementHistory( - loadGlobalRefinementHistory(getGlobalHarnessStateDir()), + [ + ...loadSharedRefinementHistory(getGlobalHarnessStateDir(), "global"), + ...loadSharedRefinementHistory(this._projectHarnessStateDir(), "project"), + ], getRefinementHistory(this.sessionManager.getEntries().filter((entry) => entry.type === "custom")), ); } @@ -7588,14 +7642,7 @@ export class AgentSession { * (`_waitForRefineIdle` only waits for `_refineInFlight`). Only the fast * application phase (disk I/O + in-memory mutation) blocks turn entry points. */ - async refine( - options: { - instructions?: string; - rollbackId?: string; - global?: boolean; - } = {}, - internal: { skipAbort?: boolean } = {}, - ): Promise { + async refine(options: RefineOptions = {}, internal: { skipAbort?: boolean } = {}): Promise { // Queued /refine executes from the session-input pump between turns; // refine never aborts the agent (planning is backgrounded and the apply // phase waits for quiescence), so skipAbort only asserts the pump's @@ -7718,10 +7765,7 @@ export class AgentSession { * Does not disconnect from or abort the agent. Returns the plan without * applying anything. */ - private async _planRefine( - options: { instructions?: string; rollbackId?: string; global?: boolean }, - signal: AbortSignal, - ): Promise { + private async _planRefine(options: RefineOptions, signal: AbortSignal): Promise { if (this._disposed) { throw new Error("Cannot refine a disposed session."); } @@ -7732,36 +7776,34 @@ export class AgentSession { const model = this.model; const { apiKey, headers } = await this._getRequiredRequestAuth(model); - const globalHarnessStateDir = getGlobalHarnessStateDir(); const localHarnessStateDir = this._localHarnessStateDir(); - const requestedScope = options.global ? "global" : "local"; + const requestedScope: HarnessScope = options.scope ?? "local"; if (!options.rollbackId && requestedScope === "local" && !localHarnessStateDir) { - throw new Error("Local harness refinement requires a persisted session; use global refinement instead."); + throw new Error( + "Local harness refinement requires a persisted session; use project or global refinement instead.", + ); } - const globalPlanningState = loadHarnessState(globalHarnessStateDir, "global"); - const localPlanningState = localHarnessStateDir ? loadHarnessState(localHarnessStateDir, "local") : undefined; + const scopedPlanningStates = this._loadScopedHarnessStates(); const planningState = - requestedScope === "global" - ? globalPlanningState - : mergeHarnessStates(globalPlanningState, localPlanningState); + requestedScope === "local" ? mergeHarnessStates(scopedPlanningStates) : scopedPlanningStates[requestedScope]!; const history = this._loadRefinementHistory(); const rollbackTarget = options.rollbackId ? history.find((item) => item.id === options.rollbackId) : undefined; let baselineScope = rollbackTarget ? (inferRefinementResultScope(rollbackTarget) ?? requestedScope) : requestedScope; - let baselineHarnessStateDir = baselineScope === "global" ? globalHarnessStateDir : localHarnessStateDir; + let baselineHarnessStateDir = this._harnessStateDirForScope(baselineScope); if (rollbackTarget?.harnessStatePath) { baselineHarnessStateDir = dirname(rollbackTarget.harnessStatePath); - baselineScope = resolve(baselineHarnessStateDir) === resolve(globalHarnessStateDir) ? "global" : "local"; + baselineScope = this._scopeForHarnessStateDir(baselineHarnessStateDir); } if (!baselineHarnessStateDir) { - throw new Error("Local harness refinement requires a persisted session; use global refinement instead."); + throw new Error( + "Local harness refinement requires a persisted session; use project or global refinement instead.", + ); } const baselineState = rollbackTarget ? loadHarnessState(baselineHarnessStateDir, baselineScope) - : baselineScope === "global" - ? globalPlanningState - : localPlanningState!; + : scopedPlanningStates[baselineScope]!; const plan = await planRefinement( this.agent.state.messages, planningState, @@ -7786,7 +7828,7 @@ export class AgentSession { */ private async _applyRefine( plan: RefinementPlan, - options: { instructions?: string; rollbackId?: string; global?: boolean }, + options: RefineOptions, refineAbort: AbortController, ): Promise { if (this._disposed) { @@ -7797,13 +7839,11 @@ export class AgentSession { this._disconnectFromAgent(); try { - const globalHarnessStateDir = getGlobalHarnessStateDir(); - const localHarnessStateDir = this._localHarnessStateDir(); - const requestedScope = options.global ? "global" : "local"; + const requestedScope: HarnessScope = options.scope ?? "local"; const history = this._loadRefinementHistory(); const rollbackTarget = options.rollbackId ? history.find((item) => item.id === options.rollbackId) : undefined; let targetScope = plan.rollbackScope ?? requestedScope; - let targetHarnessStateDir = targetScope === "global" ? globalHarnessStateDir : localHarnessStateDir; + let targetHarnessStateDir = this._harnessStateDirForScope(targetScope); if (targetScope === "local" && rollbackTarget?.harnessStatePath) { if (!existsSync(rollbackTarget.harnessStatePath)) { throw new Error( @@ -7812,31 +7852,23 @@ export class AgentSession { } targetHarnessStateDir = dirname(rollbackTarget.harnessStatePath); // Legacy records predate scope fields and default to "local" but may point - // at the global store; honor the recorded path so its entries stay global. - if (resolve(targetHarnessStateDir) === resolve(globalHarnessStateDir)) { - targetScope = "global"; - } + // at a persisted store; honor the recorded path so its entries keep their scope. + targetScope = this._scopeForHarnessStateDir(targetHarnessStateDir); } if (!targetHarnessStateDir) { - throw new Error("Local harness refinement requires a persisted session; use global refinement instead."); + throw new Error( + "Local harness refinement requires a persisted session; use project or global refinement instead.", + ); } // Re-read the target state immediately before applying so concurrent kernel // (`rlm.harness`) writes during the LLM pass are not clobbered. const state = loadHarnessState(targetHarnessStateDir, targetScope); const proposal = { ...plan.proposal, - edits: plan.proposal.edits.map((edit) => { - const localPrefix = "local:"; - const globalPrefix = "global:"; - return { - ...edit, - id: edit.id?.startsWith(localPrefix) - ? edit.id.slice(localPrefix.length) - : edit.id?.startsWith(globalPrefix) - ? edit.id.slice(globalPrefix.length) - : edit.id, - }; - }), + edits: plan.proposal.edits.map((edit) => ({ + ...edit, + id: stripHarnessScopePrefix(edit.id), + })), }; if (this._disposed || refineAbort.signal.aborted) { throw new Error("Refinement cancelled because the session was disposed."); @@ -7848,8 +7880,8 @@ export class AgentSession { baselineState: plan.baselineState, }); result.harnessStatePath = saveHarnessState(targetHarnessStateDir, state); - if (targetScope === "global") { - appendGlobalRefinement(globalHarnessStateDir, result); + if (targetScope !== "local") { + appendSharedRefinement(targetHarnessStateDir, result); } this.sessionManager.appendCustomEntry("prime-agent.refinement", result); this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); @@ -8823,6 +8855,7 @@ export class AgentSession { RLM_DEPTH: String(this._rlmDepth), RLM_MAX_DEPTH: String(this._rlmMaxDepth), RLM_GLOBAL_HARNESS_STATE_DIR: getGlobalHarnessStateDir(), + RLM_PROJECT_HARNESS_STATE_DIR: this._projectHarnessStateDir(), }; const rlmSessionDir = this._ensureRlmSessionDir(); if (rlmSessionDir) { @@ -10557,7 +10590,7 @@ export class AgentSession { } /** Persist and immediately apply a per-chat RLM max-depth override. */ - async setRlmMaxDepth(maxDepth: number, options: { global?: boolean } = {}): Promise { + async setRlmMaxDepth(maxDepth: number, options: { scope?: SettingsScope } = {}): Promise { if (!isNonNegativeInteger(maxDepth)) { throw new Error("RLM max depth must be a non-negative integer."); } @@ -10569,23 +10602,28 @@ export class AgentSession { this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); this.agent.state.systemPrompt = this._refreshExtensionSystemPrompt(this.agent.state.systemPrompt, oldBase); - let globalError: string | undefined; - if (options.global) { + const scope = options.scope; + let saveError: string | undefined; + if (scope) { await this.settingsManager.flush(); - const staleErrors = this.settingsManager.drainErrors("global"); + const staleErrors = this.settingsManager.drainErrors(scope); for (const { error } of staleErrors) { - console.warn(`Warning: Earlier global settings write failed: ${error.message}`); + console.warn(`Warning: Earlier ${scope} settings write failed: ${error.message}`); } - this.settingsManager.setRlmMaxDepth(maxDepth); + this.settingsManager.setRlmMaxDepth(maxDepth, scope); await this.settingsManager.flush(); - const errors = this.settingsManager.drainErrors("global"); - globalError = errors.map(({ error }) => error.message).join("; ") || undefined; + const errors = this.settingsManager.drainErrors(scope); + saveError = errors.map(({ error }) => error.message).join("; ") || undefined; } + const saved = scope !== undefined && saveError === undefined; return { ...this.getRlmMaxDepthStatus(), - globalSaved: options.global === true && globalError === undefined, - ...(globalError ? { globalError } : {}), + ...(scope ? { savedScope: scope } : {}), + globalSaved: scope === "global" && saved, + ...(scope === "global" && saveError ? { globalError: saveError } : {}), + ...(scope === "project" ? { projectSaved: saved } : {}), + ...(scope === "project" && saveError ? { projectError: saveError } : {}), }; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 89ff11dcf..94c017465 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -49,6 +49,7 @@ import type { ReadonlyFooterDataProvider } from "../footer-data-provider.js"; import type { KeybindingsManager } from "../keybindings.js"; import type { CustomMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; +import type { HarnessScope } from "../refinement/index.js"; import type { BranchSummaryEntry, CompactionEntry, @@ -654,8 +655,8 @@ export interface RefineCompleteEvent { summary: string; /** Number of edits applied. */ appliedEdits: number; - /** Whether the refinement was applied to the global or local harness. */ - scope: "global" | "local"; + /** Which harness store the refinement was applied to. */ + scope: HarnessScope; } /** Fired at the start of each turn */ diff --git a/packages/coding-agent/src/core/kernel/bootstrap.ts b/packages/coding-agent/src/core/kernel/bootstrap.ts index 9b12b4b41..3ee4398e1 100644 --- a/packages/coding-agent/src/core/kernel/bootstrap.ts +++ b/packages/coding-agent/src/core/kernel/bootstrap.ts @@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url"; import { getPackageDir } from "../../config.js"; import type { PythonSkillRuntimeInfo } from "../skills.js"; -const BOOTSTRAP_SCHEMA = 8; +const BOOTSTRAP_SCHEMA = 9; const PYTHON_VERSION = "3.11"; const IPYKERNEL_REQUIREMENT = "ipykernel"; const RUNTIME_REQUIREMENT = "prime-agent-runtime"; @@ -51,7 +51,7 @@ const REQUIRED_HARNESS_METHODS = [ "delete_prompt_note", "record_refinement", ]; -const RUNTIME_READY_CHECK = `import inspect; import rlm; from rlm import McpIntegration; from rlm.harness import HarnessEntry; _harness_methods = ${JSON.stringify(REQUIRED_HARNESS_METHODS)}; assert hasattr(rlm, 'run'); assert callable(rlm); assert hasattr(rlm, 'rlm'); assert callable(rlm.rlm); assert callable(rlm.host_request); assert callable(rlm.find_models); assert callable(rlm.rlm.find_models); assert hasattr(rlm, 'harness'); assert hasattr(rlm, 'get_harness_state'); assert hasattr(rlm.rlm, 'harness'); assert hasattr(rlm.rlm, 'get_harness_state'); assert all(callable(getattr(_harness, _method, None)) for _harness in (rlm.harness, rlm.rlm.harness) for _method in _harness_methods); assert 'reference' in HarnessEntry.__dataclass_fields__; assert 'scope' in HarnessEntry.__dataclass_fields__; assert 'reference' in inspect.signature(rlm.harness.create_skill).parameters; assert 'reference' in inspect.signature(rlm.harness.update_skill).parameters; assert 'global_' in inspect.signature(rlm.harness.create_memory).parameters; assert 'global_' in inspect.signature(rlm.get_harness_state).parameters; assert not hasattr(rlm, 'background'); assert not hasattr(rlm.rlm, 'background')`; +const RUNTIME_READY_CHECK = `import inspect; import rlm; from rlm import McpIntegration; from rlm.harness import HarnessEntry; _harness_methods = ${JSON.stringify(REQUIRED_HARNESS_METHODS)}; assert hasattr(rlm, 'run'); assert callable(rlm); assert hasattr(rlm, 'rlm'); assert callable(rlm.rlm); assert callable(rlm.host_request); assert callable(rlm.find_models); assert callable(rlm.rlm.find_models); assert hasattr(rlm, 'harness'); assert hasattr(rlm, 'get_harness_state'); assert hasattr(rlm.rlm, 'harness'); assert hasattr(rlm.rlm, 'get_harness_state'); assert all(callable(getattr(_harness, _method, None)) for _harness in (rlm.harness, rlm.rlm.harness) for _method in _harness_methods); assert 'reference' in HarnessEntry.__dataclass_fields__; assert 'scope' in HarnessEntry.__dataclass_fields__; assert 'reference' in inspect.signature(rlm.harness.create_skill).parameters; assert 'reference' in inspect.signature(rlm.harness.update_skill).parameters; assert 'scope' in inspect.signature(rlm.harness.create_memory).parameters; assert 'scope' in inspect.signature(rlm.get_harness_state).parameters; assert not hasattr(rlm, 'background'); assert not hasattr(rlm.rlm, 'background')`; const BOOTSTRAP_VERSION_FILE = ".bootstrap-version"; const BOOTSTRAP_LOCK_NAME = ".bootstrap.lock"; const BOOTSTRAP_LOCK_RETRY_MS = 100; diff --git a/packages/coding-agent/src/core/prompts/rlm.ts b/packages/coding-agent/src/core/prompts/rlm.ts index 7e023d527..dd19e7b43 100644 --- a/packages/coding-agent/src/core/prompts/rlm.ts +++ b/packages/coding-agent/src/core/prompts/rlm.ts @@ -26,7 +26,7 @@ const IPYTHON_CONTROL_PROMPT = [ "", "Python state in the kernel, by contrast, persists across cells: named variables, helper functions, classes, imports, notes, parsed outputs, and helper data structures all remain available in every later turn. Tool calls are themselves Python `await` expressions, so their return values can be bound to variables and composed into program logic just like any other call.", "", - "Continual harness state is available as `rlm.harness` and `rlm.get_harness_state()`. CRUD calls are local to this Prime Agent session by default: `rlm.harness.create_memory(...)`, `rlm.harness.update_memory(...)`, `rlm.harness.delete_memory(...)`, `rlm.harness.create_skill(...)`, `rlm.harness.update_skill(...)`, `rlm.harness.delete_skill(...)`, `rlm.harness.create_subagent(...)`, `rlm.harness.update_subagent(...)`, `rlm.harness.delete_subagent(...)`, `rlm.harness.create_prompt_note(...)`, `rlm.harness.update_prompt_note(...)`, `rlm.harness.delete_prompt_note(...)`, plus `rlm.harness.record_refinement(...)` and `rlm.harness.overview()`. Use `global_=True` only for stable cross-session lessons; Python reserves `global`, so literal `global=True` is invalid syntax.", + 'Continual harness state is available as `rlm.harness` and `rlm.get_harness_state()`. CRUD calls are local to this Prime Agent session by default: `rlm.harness.create_memory(...)`, `rlm.harness.update_memory(...)`, `rlm.harness.delete_memory(...)`, `rlm.harness.create_skill(...)`, `rlm.harness.update_skill(...)`, `rlm.harness.delete_skill(...)`, `rlm.harness.create_subagent(...)`, `rlm.harness.update_subagent(...)`, `rlm.harness.delete_subagent(...)`, `rlm.harness.create_prompt_note(...)`, `rlm.harness.update_prompt_note(...)`, `rlm.harness.delete_prompt_note(...)`, plus `rlm.harness.record_refinement(...)` and `rlm.harness.overview()`. Pass `scope="project"` for repository-specific entries that should persist across sessions in this repository, and `scope="global"` for stable cross-project lessons.', "", "Terminology: continual harness names the persisted prompt, memory, skill, and subagent layer; RLM names the runtime, IPython kernel, and native call interface exposed to the model.", "", diff --git a/packages/coding-agent/src/core/refinement/refinement.ts b/packages/coding-agent/src/core/refinement/refinement.ts index b19b33db6..bb55a3315 100644 --- a/packages/coding-agent/src/core/refinement/refinement.ts +++ b/packages/coding-agent/src/core/refinement/refinement.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; -import { getAgentDir } from "../../config.js"; +import { getAgentDir, getProjectConfigDir } from "../../config.js"; import { serializeConversation } from "../compaction/utils.js"; import { convertToLlm } from "../messages.js"; import type { CustomEntry } from "../session-manager.js"; @@ -29,7 +29,10 @@ const DEFAULT_OVERVIEW_CONTENT_LIMIT = 180; export type RefinementKind = "prompt" | "memory" | "skill" | "subagent"; export type RefinementAction = "create" | "update" | "delete"; -export type HarnessScope = "local" | "global"; +export type HarnessScope = "local" | "project" | "global"; + +/** Merge order: later scopes overlay earlier ones and win id collisions. */ +export const HARNESS_SCOPES: readonly HarnessScope[] = ["global", "project", "local"]; export interface HarnessEntry { id: string; @@ -104,7 +107,7 @@ export interface RefinementResult { export interface RefineOptions { instructions?: string; rollbackId?: string; - global?: boolean; + scope?: HarnessScope; } export type AutoRefineReason = "turn_interval" | "compact"; @@ -138,14 +141,15 @@ Continual harness components: - subagent: reusable delegation specs, including purpose, instructions, and when to invoke. Include the RLM-native call form: compose a concise task prompt and spawn with \`handle = await rlm("sub-task")\`; admission returns immediately with \`rlm_child_id\`, \`name\`, \`session_dir\`, and \`model\`, never the child's answer. Results arrive only through explicit \`agent_message\` replies or files; children reply with \`await agent_message.send(message, receiver_role="parent")\`. Use \`await rlm.list_subagents()\` to recover direct child handles and \`await agent_message.send(..., receiver_role="child", receiver_name=handle.name)\` for follow-ups. Do not invent wrappers like \`run_subagent(...)\`. Scope and persistence policy: -- The default editable continual harness store is local to the current Prime Agent session. Use it for session-specific progress, active task state, current-run coordination notes, temporary blockers, and project facts that should not affect other sessions. -- A caller may explicitly request global refinement. Global edits must be stable cross-session lessons, durable user preferences, reusable skills/subagents, or tool/environment facts that should affect future sessions. -- Entry ids in the harness overview may carry a display-only \`local:\` or \`global:\` prefix. Always use the bare id (no prefix) in edits. -- All edits in one refinement apply only to the requested scope's store. During a local refinement, global entries are read-only context: never propose update or delete edits for them; create a local entry instead when a session-specific override is genuinely needed. -- Project/workspace-specific lessons may be persisted globally only when the title, path, or content explicitly names the project/workspace and the lesson is likely to be reused in future sessions for that project. Prefer local edits when the lesson only belongs in the current conversation. +- There are three editable stores. \`local\` belongs to the current Prime Agent session, \`project\` belongs to the current repository/workspace, and \`global\` belongs to the user across every project. +- The default store is local. Use it for session-specific progress, active task state, current-run coordination notes, temporary blockers, and facts that should not affect other sessions. +- Project edits must be repository-specific and reusable in future sessions on that repository: build/test commands, layout, conventions, recurring pitfalls, and project-scoped subagent specs. Never persist another project's facts or user-wide preferences there. +- Global edits must be stable cross-session lessons, durable user preferences, reusable skills/subagents, or tool/environment facts that apply regardless of which repository is open. Project-specific detail does not belong in the global store. +- Entry ids in the harness overview may carry a display-only \`local:\`, \`project:\`, or \`global:\` prefix. Always use the bare id (no prefix) in edits. +- All edits in one refinement apply only to the requested scope's store. Entries from the other scopes are read-only context: never propose update or delete edits for them; create an entry in the requested scope instead when an override is genuinely needed. - Use memory for declarative facts and preferences, skill for repeatable procedures exposed as Python calls, prompt for narrow behavioral policy addendums, and subagent for reusable delegation roles. - Create or update the smallest relevant component: repeated delegation roles should become subagent specs, repeated procedures should become skills, durable facts/preferences should become memories, and narrow behavioral policies should become prompt addendums. -- When an edit is persisted, include metadata such as \`{"scope":"local"}\` or \`{"scope":"global"}\` when that helps future review understand the intended blast radius. +- When an edit is persisted, include metadata such as \`{"scope":"local"}\`, \`{"scope":"project"}\`, or \`{"scope":"global"}\` when that helps future review understand the intended blast radius. Use the trajectory, current continual harness state, and prior refinement history. Prefer small evidence-backed edits. If prior refinements caused issues, rollback or @@ -175,7 +179,7 @@ JSON only with this exact shape: const AUTO_REFINE_REVIEW_SYSTEM_PROMPT = `You are Prime Agent's automatic /refine review gate. Decide whether this checkpoint should run /refine. Auto /refine writes local continual harness state by default, so approve when the trajectory contains evidence useful to this session's future turns. -Reject one-off noise, unsupported hypotheses, and transient tool outputs. Ask for global refinement only for durable cross-session lessons or explicitly project-qualified lessons likely to be reused in future sessions. +Reject one-off noise, unsupported hypotheses, and transient tool outputs. Ask for project refinement only for repository-specific lessons likely to be reused in future sessions on this repository, and for global refinement only for durable cross-project lessons. Return JSON only: { @@ -243,7 +247,7 @@ function objectRecord(value: unknown): Record | undefined { } function normalizeHarnessScope(value: unknown, fallback: HarnessScope): HarnessScope { - return value === "global" || value === "local" ? value : fallback; + return HARNESS_SCOPES.includes(value as HarnessScope) ? (value as HarnessScope) : fallback; } export function inferRefinementResultScope(result: RefinementResult): HarnessScope | undefined { @@ -270,6 +274,11 @@ export function getGlobalHarnessStateDir(agentDir: string = getAgentDir()): stri return join(agentDir, HARNESS_STATE_DIR_NAME); } +/** Per-repository harness store, next to the project settings file. */ +export function getProjectHarnessStateDir(cwd: string): string { + return join(getProjectConfigDir(cwd), HARNESS_STATE_DIR_NAME); +} + export function getLocalHarnessStateDir(sessionArtifactDir: string | undefined): string | undefined { return sessionArtifactDir ? join(sessionArtifactDir, HARNESS_STATE_DIR_NAME) : undefined; } @@ -323,22 +332,30 @@ export function loadHarnessState( return state; } -export function mergeHarnessStates(globalState: HarnessState, localState?: HarnessState): HarnessState { +export interface ScopedHarnessStates { + global: HarnessState; + project?: HarnessState; + local?: HarnessState; +} + +/** + * Overlay the scoped stores into one view. Narrower scopes win id collisions and + * keep their `:` display key so the model can address either entry. + */ +export function mergeHarnessStates(states: ScopedHarnessStates): HarnessState { const merged = emptyHarnessState(); - merged.schema = Math.max(globalState.schema, localState?.schema ?? 1); + merged.schema = Math.max(states.global.schema, states.project?.schema ?? 1, states.local?.schema ?? 1); for (const kind of Object.keys(merged.entries) as RefinementKind[]) { - for (const [id, entry] of Object.entries(globalState.entries[kind])) { - const cloned = cloneEntry(entry)!; - merged.entries[kind][id] = { ...cloned, scope: normalizeHarnessScope(cloned.scope, "global") }; - } - for (const [id, entry] of Object.entries(localState?.entries[kind] ?? {})) { - const cloned = cloneEntry(entry)!; - const scopedEntry = { ...cloned, scope: normalizeHarnessScope(cloned.scope, "local") }; - const mergedId = merged.entries[kind][id] ? `${scopedEntry.scope}:${id}` : id; - merged.entries[kind][mergedId] = scopedEntry; + for (const scope of HARNESS_SCOPES) { + for (const [id, entry] of Object.entries(states[scope]?.entries[kind] ?? {})) { + const cloned = cloneEntry(entry)!; + const scopedEntry = { ...cloned, scope: normalizeHarnessScope(cloned.scope, scope) }; + const mergedId = merged.entries[kind][id] ? `${scopedEntry.scope}:${id}` : id; + merged.entries[kind][mergedId] = scopedEntry; + } } } - merged.refinements = [...globalState.refinements, ...(localState?.refinements ?? [])]; + merged.refinements = HARNESS_SCOPES.flatMap((scope) => states[scope]?.refinements ?? []); return merged; } @@ -367,18 +384,22 @@ function isRefinementResult(data: unknown): data is RefinementResult { } /** - * Append a global-scope refinement to the cross-session history log so it can be - * rolled back from any session. Local-scope refinements are recorded only in the - * session JSONL and roll back via their recorded harnessStatePath. + * Append a persisted-scope (global or project) refinement to its cross-session + * history log so it can be rolled back from any session. Local-scope refinements + * are recorded only in the session JSONL and roll back via their recorded + * harnessStatePath. */ -export function appendGlobalRefinement(harnessStateDir: string, result: RefinementResult): string { +export function appendSharedRefinement(harnessStateDir: string, result: RefinementResult): string { const historyPath = getRefinementHistoryPath(harnessStateDir); mkdirSync(harnessStateDir, { recursive: true }); appendFileSync(historyPath, `${JSON.stringify(result)}\n`, "utf8"); return historyPath; } -export function loadGlobalRefinementHistory(harnessStateDir: string = getGlobalHarnessStateDir()): RefinementResult[] { +export function loadSharedRefinementHistory( + harnessStateDir: string = getGlobalHarnessStateDir(), + scope: HarnessScope = "global", +): RefinementResult[] { const historyPath = getRefinementHistoryPath(harnessStateDir); if (!existsSync(historyPath)) { return []; @@ -390,7 +411,7 @@ export function loadGlobalRefinementHistory(harnessStateDir: string = getGlobalH try { const parsed = JSON.parse(trimmed); if (isRefinementResult(parsed)) { - results.push(withDefaultRefinementScope(parsed, "global")); + results.push(withDefaultRefinementScope(parsed, scope)); } } catch { // Skip malformed lines so a single bad append cannot break rollback. @@ -400,15 +421,16 @@ export function loadGlobalRefinementHistory(harnessStateDir: string = getGlobalH } /** - * Merge global and session refinement history, de-duplicating by id. Session entries - * win on conflict so a session that is mid-flight still resolves its own latest result. + * Merge persisted (global, project) and session refinement history, de-duplicating + * by id. Session entries win on conflict so a session that is mid-flight still + * resolves its own latest result. */ export function mergeRefinementHistory( - global: readonly RefinementResult[], + persisted: readonly RefinementResult[], session: readonly RefinementResult[], ): RefinementResult[] { const byId = new Map(); - for (const result of global) { + for (const result of persisted) { byId.set(result.id, result); } for (const result of session) { @@ -445,14 +467,14 @@ export function formatHarnessStateForPrompt( const lines = [ "# Continual Harness State", "", - "Local continual harness entries belong to this Prime Agent session. Global continual harness entries persist across Prime Agent sessions.", + "Local continual harness entries belong to this Prime Agent session. Project entries belong to the current repository and persist across sessions in it. Global entries persist across every Prime Agent session and project.", "The continual harness entries below are compact summaries, not full descriptions. Use them as routing/context hints; inspect or refine the underlying continual harness entry only when detail matters.", - "Default to local continual harness refinement for current task progress, temporary blockers, and session coordination. Use global continual harness refinement only for stable cross-session lessons, durable user preferences, reusable skills/subagents, or explicitly project-qualified facts.", + "Default to local continual harness refinement for current task progress, temporary blockers, and session coordination. Use project refinement for repository-specific conventions, commands, and pitfalls worth reusing in this repository. Use global refinement only for stable cross-project lessons, durable user preferences, and reusable skills/subagents.", "Use these continual harness prompt notes, memories, skills, and subagent specs when they are relevant. The base system prompt is immutable; prompt entries below are supplemental notes only.", "", includeRefineExamples - ? "When to call `await refine.run()`: after a repeated failure, a reusable tactic emerges, a repeated delegation role should become a subagent spec, a repeated procedure should become a skill, a durable fact/preference should become a memory, a narrow behavioral policy should become a prompt addendum, a user corrects behavior that should persist locally or globally, validation shows a continual harness entry is wrong, or a skill/subagent/memory/prompt note should be created, updated, deleted, or rolled back. Keep `await refine.run()` continual harness edits small and evidence-backed." - : "When to refine the continual harness: after a repeated failure, a reusable tactic emerges, a repeated delegation role should become a subagent spec, a repeated procedure should become a skill, a durable fact/preference should become a memory, a narrow behavioral policy should become a prompt addendum, a user corrects behavior that should persist locally or globally, validation shows a continual harness entry is wrong, or a skill/subagent/memory/prompt note should be created, updated, deleted, or rolled back. Keep continual harness edits small and evidence-backed.", + ? 'When to call `await refine.run()`: after a repeated failure, a reusable tactic emerges, a repeated delegation role should become a subagent spec, a repeated procedure should become a skill, a durable fact/preference should become a memory, a narrow behavioral policy should become a prompt addendum, a user corrects behavior that should persist for this session, this repository, or every project, validation shows a continual harness entry is wrong, or a skill/subagent/memory/prompt note should be created, updated, deleted, or rolled back. Pass `scope="project"` for repository-specific lessons and `scope="global"` for cross-project ones. Keep `await refine.run()` continual harness edits small and evidence-backed.' + : "When to refine the continual harness: after a repeated failure, a reusable tactic emerges, a repeated delegation role should become a subagent spec, a repeated procedure should become a skill, a durable fact/preference should become a memory, a narrow behavioral policy should become a prompt addendum, a user corrects behavior that should persist for this session, this repository, or every project, validation shows a continual harness entry is wrong, or a skill/subagent/memory/prompt note should be created, updated, deleted, or rolled back. Keep continual harness edits small and evidence-backed.", "", includeIpythonExamples ? "Call contract: read each installed Python skill's SKILL.md and call its documented module function in IPython; do not assume a `.run` entrypoint. Use ` ...` in shell when a CLI exists. Continual harness skill entries are Python REPL skills with an explicit Python `reference` and `arguments` contract. Spawn a continual harness subagent spec by composing a concise task prompt and calling `handle = await rlm('sub-task')`; admission returns immediately with `rlm_child_id`, `name`, `session_dir`, and `model`, never the child's answer. Results arrive only through explicit `agent_message` replies or files; children reply with `await agent_message.send(message, receiver_role='parent')`. Use `await rlm.list_subagents()` to recover direct child handles and `await agent_message.send(..., receiver_role='child', receiver_name=handle.name)` for follow-ups. Do not invent wrappers such as `call_skill(...)`, `run_subagent(...)`, or named subagent registries." @@ -853,6 +875,17 @@ export interface RefinementPlan { baselineState?: HarnessState; } +function refinementScopeInstruction(scope: HarnessScope): string { + switch (scope) { + case "global": + return "Requested refinement scope: global. Only propose stable cross-project continual harness edits, durable user preferences, reusable skills/subagents, or tool/environment facts that should affect every future Prime Agent session. Do not persist session-only progress, temporary blockers, or repository-specific detail globally."; + case "project": + return "Requested refinement scope: project. Only propose edits that describe the current repository/workspace and are reusable in future sessions on it: build/test commands, layout, conventions, recurring pitfalls, and project-scoped subagent specs. Do not persist session-only progress or user-wide preferences here. Local and global entries in the overview are read-only context."; + default: + return "Requested refinement scope: local. Prefer local continual harness edits for current task progress, temporary blockers, current-run coordination, and facts that are not clearly reusable in future sessions. Project and global entries in the overview are read-only context: do not propose update or delete edits for them; create a local entry instead if an override is needed."; + } +} + /** * Produce a refinement proposal (the LLM pass, or a rollback proposal) without * mutating any harness state. Separated from {@link applyRefinementProposal} so @@ -880,7 +913,7 @@ export async function planRefinement( if (!target) { throw new Error(`Refinement ${options.rollbackId} not found`); } - const fallbackScope: HarnessScope = options.global ? "global" : "local"; + const fallbackScope: HarnessScope = options.scope ?? "local"; return { proposal: rollbackProposal(target), id, @@ -890,9 +923,7 @@ export async function planRefinement( } const conversationText = serializeConversation(convertToLlm(messages)).slice(-80_000); - const scopeInstruction = options.global - ? "Requested refinement scope: global. Only propose stable cross-session continual harness edits, durable user preferences, reusable skills/subagents, or explicitly project-qualified facts that should affect future Prime Agent sessions. Do not persist session-only progress, temporary blockers, or current-run coordination globally." - : "Requested refinement scope: local. Prefer local continual harness edits for current task progress, temporary blockers, current-run coordination, and project facts that are not clearly reusable across Prime Agent sessions. Global entries in the overview are read-only context: do not propose update or delete edits for them; create a local entry instead if an override is needed."; + const scopeInstruction = refinementScopeInstruction(options.scope ?? "local"); const userPrompt = [ `\n${overviewForPrompt(state)}\n`, `\n${historyForPrompt(history)}\n`, @@ -971,7 +1002,7 @@ ${historyForPrompt(history)} ` ${conversationText} `, - "Return shouldRefine=true when the trajectory contains evidence useful to this session's future turns. Prefer local harness edits for current task progress, temporary blockers, and current-run coordination. Ask for global refinement only for durable cross-session lessons or explicitly project-qualified facts likely to be reused in future sessions.", + "Return shouldRefine=true when the trajectory contains evidence useful to this session's future turns. Prefer local harness edits for current task progress, temporary blockers, and current-run coordination. Ask for project refinement only for repository-specific facts likely to be reused in future sessions on this repository, and for global refinement only for durable cross-project lessons.", ].join("\n\n"); // Auto-refine review requires parseable JSON. Keep it non-reasoning so // reasoning-capable models use final text budget for the JSON object. @@ -1012,6 +1043,6 @@ export async function refineHarness( return applyRefinementProposal(state, plan.proposal, { id: plan.id, rollbackOf: plan.rollbackOf, - scope: plan.rollbackScope ?? (options.global ? "global" : "local"), + scope: plan.rollbackScope ?? options.scope ?? "local", }); } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 5cb03a4bf..54bb2d5f7 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve, sep } from "node:path"; import chalk from "chalk"; -import { CONFIG_DIR_NAME, getBundledSkillsDir } from "../config.js"; +import { CONFIG_DIR_NAME, getBundledSkillsDir, getProjectConfigDir, getProjectDir } from "../config.js"; import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js"; import type { ResourceDiagnostic } from "./diagnostics.js"; @@ -15,7 +15,7 @@ import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResul import { DefaultPackageManager, type PathMetadata } from "./package-manager.js"; import type { PromptTemplate } from "./prompt-templates.js"; import { loadPromptTemplates } from "./prompt-templates.js"; -import { SettingsManager } from "./settings-manager.js"; +import { type ContextFilesSettings, SettingsManager } from "./settings-manager.js"; import type { Skill } from "./skills.js"; import { loadSkills } from "./skills.js"; import { createSourceInfo, type SourceInfo } from "./source-info.js"; @@ -76,22 +76,28 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } export function loadProjectContextFiles(options: { cwd: string; agentDir: string; + settings?: ContextFilesSettings; }): Array<{ path: string; content: string }> { const resolvedCwd = options.cwd; const resolvedAgentDir = options.agentDir; + const includeGlobal = options.settings?.global ?? true; + const includeAncestors = options.settings?.ancestors ?? true; + const projectDir = resolve(getProjectDir(resolvedCwd)); const contextFiles: Array<{ path: string; content: string }> = []; const seenPaths = new Set(); - const globalContext = loadContextFileFromDir(resolvedAgentDir); - if (globalContext) { - contextFiles.push(globalContext); - seenPaths.add(globalContext.path); + if (includeGlobal) { + const globalContext = loadContextFileFromDir(resolvedAgentDir); + if (globalContext) { + contextFiles.push(globalContext); + seenPaths.add(globalContext.path); + } } const ancestorContextFiles: Array<{ path: string; content: string }> = []; - let currentDir = resolvedCwd; + let currentDir = resolve(resolvedCwd); const root = resolve("/"); while (true) { @@ -101,7 +107,9 @@ export function loadProjectContextFiles(options: { seenPaths.add(contextFile.path); } - if (currentDir === root) break; + // Stopping at the project root keeps unrelated repositories and the home + // directory from leaking their instructions into this project. + if (currentDir === root || (!includeAncestors && currentDir === projectDir)) break; const parentDir = resolve(currentDir, ".."); if (parentDir === currentDir) break; @@ -472,8 +480,16 @@ export class DefaultResourceLoader implements ResourceLoader { } } + const contextFilesSettings = this.settingsManager.getContextFiles(); + const contextFilesDisabled = this.noContextFiles || !contextFilesSettings.enabled; const agentsFiles = { - agentsFiles: this.noContextFiles ? [] : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }), + agentsFiles: contextFilesDisabled + ? [] + : loadProjectContextFiles({ + cwd: this.cwd, + agentDir: this.agentDir, + settings: contextFilesSettings, + }), }; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; this.agentsFiles = resolvedAgentsFiles.agentsFiles; @@ -861,13 +877,14 @@ export class DefaultResourceLoader implements ResourceLoader { return { themes: Array.from(seen.values()), diagnostics }; } - private discoverSystemPromptFile(): string | undefined { - const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); + /** Project file first, then the global one. Both are resolved for the project root, not the cwd. */ + private discoverConfigDirFile(fileName: string): string | undefined { + const projectPath = join(getProjectConfigDir(this.cwd), fileName); if (existsSync(projectPath)) { return projectPath; } - const globalPath = join(this.agentDir, "SYSTEM.md"); + const globalPath = join(this.agentDir, fileName); if (existsSync(globalPath)) { return globalPath; } @@ -875,18 +892,12 @@ export class DefaultResourceLoader implements ResourceLoader { return undefined; } - private discoverAppendSystemPromptFile(): string | undefined { - const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); - if (existsSync(projectPath)) { - return projectPath; - } - - const globalPath = join(this.agentDir, "APPEND_SYSTEM.md"); - if (existsSync(globalPath)) { - return globalPath; - } + private discoverSystemPromptFile(): string | undefined { + return this.discoverConfigDirFile("SYSTEM.md"); + } - return undefined; + private discoverAppendSystemPromptFile(): string | undefined { + return this.discoverConfigDirFile("APPEND_SYSTEM.md"); } private isUnderPath(target: string, root: string): boolean { diff --git a/packages/coding-agent/src/core/rlm-max-depth.ts b/packages/coding-agent/src/core/rlm-max-depth.ts index e51e6f65b..358d02baa 100644 --- a/packages/coding-agent/src/core/rlm-max-depth.ts +++ b/packages/coding-agent/src/core/rlm-max-depth.ts @@ -1,6 +1,8 @@ /** Wire-safe types for the immediate /rlm-max-depth state APIs. */ -export type RlmMaxDepthSource = "default" | "env" | "global" | "inherited" | "chat"; +import type { SettingsScope } from "./settings-manager.js"; + +export type RlmMaxDepthSource = "default" | "env" | "global" | "project" | "inherited" | "chat"; export interface RlmMaxDepthStatus { maxDepth: number; @@ -8,6 +10,12 @@ export interface RlmMaxDepthStatus { } export interface SetRlmMaxDepthResult extends RlmMaxDepthStatus { + /** Settings scope the value was persisted to, when persisting was requested. */ + savedScope?: SettingsScope; + /** True only when a requested global settings write succeeded. */ globalSaved: boolean; globalError?: string; + /** True only when a requested project settings write succeeded. */ + projectSaved?: boolean; + projectError?: string; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index ab42f5e93..ce8e8aea7 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; -import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; +import { getAgentDir, getProjectConfigDir } from "../config.js"; const RECENT_MODELS_LIMIT = 20; export const DEFAULT_IDLE_EVICTION_MINUTES = 90; @@ -68,6 +68,13 @@ export interface BundledSkillsSettings { websearch?: boolean; // default: true } +/** Controls AGENTS.md / CLAUDE.md discovery. Set per project to keep unrelated instructions out. */ +export interface ContextFilesSettings { + enabled?: boolean; // default: true - load AGENTS.md/CLAUDE.md at all + global?: boolean; // default: true - include the agent dir's own AGENTS.md (e.g. ~/.prime/agent/AGENTS.md) + ancestors?: boolean; // default: true - include directories above the project root +} + export interface WarningSettings { anthropicExtraUsage?: boolean; // default: true } @@ -153,6 +160,7 @@ export interface Settings { enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands bundledSkills?: BundledSkillsSettings; // Configure built-in skills shipped with Prime Agent enableBuiltinSkills?: boolean; // default: true - load built-in skills shipped with prime-agent + contextFiles?: ContextFilesSettings; // Control AGENTS.md/CLAUDE.md discovery (global and project scoped) terminal?: TerminalSettings; images?: ImageSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) @@ -223,7 +231,7 @@ export class FileSettingsStorage implements SettingsStorage { constructor(cwd: string, agentDir: string) { this.globalSettingsPath = join(agentDir, "settings.json"); - this.projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); + this.projectSettingsPath = join(getProjectConfigDir(cwd), "settings.json"); } private acquireLockSyncWithRetry(path: string): () => void { @@ -768,11 +776,18 @@ export class SettingsManager { this.save(); } - getRlmMaxDepth(): number | undefined { - return this.globalSettings.rlmMaxDepth; + getRlmMaxDepth(scope: SettingsScope = "global"): number | undefined { + return scope === "project" ? this.projectSettings.rlmMaxDepth : this.globalSettings.rlmMaxDepth; } - setRlmMaxDepth(maxDepth: number): void { + setRlmMaxDepth(maxDepth: number, scope: SettingsScope = "global"): void { + if (scope === "project") { + const projectSettings = structuredClone(this.projectSettings); + projectSettings.rlmMaxDepth = maxDepth; + this.markProjectModified("rlmMaxDepth"); + this.saveProjectSettings(projectSettings); + return; + } this.globalSettings.rlmMaxDepth = maxDepth; this.markModified("rlmMaxDepth"); this.save(); @@ -1102,6 +1117,31 @@ export class SettingsManager { this.save(); } + getContextFiles(): Required { + return { + enabled: this.settings.contextFiles?.enabled ?? true, + global: this.settings.contextFiles?.global ?? true, + ancestors: this.settings.contextFiles?.ancestors ?? true, + }; + } + + /** Write one context-file toggle. Project scope keeps the choice with the repository. */ + setContextFilesOption(option: keyof ContextFilesSettings, enabled: boolean, scope: SettingsScope = "global"): void { + if (scope === "project") { + const projectSettings = structuredClone(this.projectSettings); + projectSettings.contextFiles = { ...projectSettings.contextFiles, [option]: enabled }; + this.markProjectModified("contextFiles", option); + this.saveProjectSettings(projectSettings); + return; + } + if (!this.globalSettings.contextFiles) { + this.globalSettings.contextFiles = {}; + } + this.globalSettings.contextFiles[option] = enabled; + this.markModified("contextFiles", option); + this.save(); + } + getThinkingBudgets(): ThinkingBudgetsSettings | undefined { return this.settings.thinkingBudgets; } diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index a3478361b..48aa9f640 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -1,4 +1,5 @@ import { APP_NAME } from "../config.js"; +import type { HarnessScope } from "./refinement/index.js"; import type { SourceInfo } from "./source-info.js"; export type SlashCommandSource = "extension" | "prompt" | "skill"; @@ -29,15 +30,24 @@ export interface SessionSlashCommand { export interface RefineCommandOptions { instructions?: string; rollbackId?: string; - global?: boolean; + scope?: HarnessScope; } +const REFINE_SCOPE_FLAGS: ReadonlyArray<{ flag: string; scope: HarnessScope }> = [ + { flag: "--global", scope: "global" }, + { flag: "--project", scope: "project" }, +]; + export function parseRefineCommandOptions(args: string): RefineCommandOptions { let rest = args.trim(); - let global = false; - if (/^--global(?=\s|$)/.test(rest)) { - global = true; - rest = rest.replace(/^--global(?=\s|$)/, "").trim(); + let scope: HarnessScope | undefined; + for (const { flag, scope: flagScope } of REFINE_SCOPE_FLAGS) { + const leading = new RegExp(`^${flag}(?=\\s|$)`); + if (leading.test(rest)) { + scope = flagScope; + rest = rest.replace(leading, "").trim(); + break; + } } if (rest === "rollback") throw new Error("Usage: /refine rollback "); // Slash-command args keep their original separators (tabs, Unicode spaces); @@ -45,17 +55,21 @@ export function parseRefineCommandOptions(args: string): RefineCommandOptions { const rollbackMatch = /^rollback[\t\p{Zs}]/u.exec(rest); if (rollbackMatch) { let rollbackId = rest.slice(rollbackMatch[0].length).trim(); - if (rollbackId === "--global") { - throw new Error("Usage: /refine rollback "); - } - if (/\s--global$/.test(rollbackId)) { - global = true; - rollbackId = rollbackId.replace(/\s--global$/, "").trim(); + for (const { flag, scope: flagScope } of REFINE_SCOPE_FLAGS) { + if (rollbackId === flag) { + throw new Error("Usage: /refine rollback "); + } + const trailing = new RegExp(`\\s${flag}$`); + if (trailing.test(rollbackId)) { + scope = flagScope; + rollbackId = rollbackId.replace(trailing, "").trim(); + break; + } } if (!rollbackId) throw new Error("Usage: /refine rollback "); - return { rollbackId, global }; + return { rollbackId, scope }; } - return { instructions: rest || undefined, global }; + return { instructions: rest || undefined, scope }; } export interface BuiltinSlashCommand { @@ -158,6 +172,7 @@ const CANONICAL_BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "refine", description: "Refine continual harness prompt notes, skills, subagents, and memory", + argumentHint: "[--project|--global] [instructions]", }, { name: "goal", diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index f5805785e..ee74eec53 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -14,10 +14,12 @@ import type { AgentHeartbeatManagementAction, AgentHeartbeatUpdateAction, } from "../../core/cron-jobs.js"; -import type { RefinementResult } from "../../core/refinement/index.js"; +import type { HarnessScope, RefinementResult } from "../../core/refinement/index.js"; +import type { RlmMaxDepthStatus, SetRlmMaxDepthResult } from "../../core/rlm-max-depth.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import { SessionAlreadyActiveError } from "../../core/session-lease.js"; import type { SessionStats } from "../../core/session-stats.js"; +import type { SettingsScope } from "../../core/settings-manager.js"; import { DaemonCapabilityUnavailableError, type DaemonClient, @@ -1035,22 +1037,35 @@ export class DaemonAgentConnection implements AgentConnection { } async refine( - options: { instructions?: string; rollbackId?: string; global?: boolean } = {}, + options: { instructions?: string; rollbackId?: string; scope?: HarnessScope } = {}, ): Promise { + const supportsScopes = this.client.supportsServerCapability("config_scopes"); + if (options.scope === "project" && !supportsScopes) { + // An older daemon would silently refine the session-local store instead + // of the repository store; fail loudly instead. + throw new Error( + "the daemon is running an older build without project harness scope; restart the daemon and try again", + ); + } const command: { type: "refine"; activeSessionId: string; instructions?: string; rollbackId?: string; global?: boolean; + scope?: HarnessScope; } = { type: "refine", activeSessionId: this.activeSessionId, instructions: options.instructions, rollbackId: options.rollbackId, }; - if (options.global !== undefined) { - command.global = options.global; + if (options.scope !== undefined) { + command.scope = options.scope; + // Older daemons only understand the boolean flag. + if (!supportsScopes) { + command.global = options.scope === "global"; + } } return this.requestData(command, DAEMON_REFINE_REQUEST_TIMEOUT_MS); } @@ -1241,24 +1256,29 @@ export class DaemonAgentConnection implements AgentConnection { await this.requestOk({ type: "set_session_name", activeSessionId: this.activeSessionId, name }); } - async getRlmMaxDepthStatus() { - return this.requestData<{ maxDepth: number; source: "default" | "env" | "global" | "inherited" | "chat" }>({ + async getRlmMaxDepthStatus(): Promise { + return this.requestData({ type: "get_rlm_max_depth_status", activeSessionId: this.activeSessionId, }); } - async setRlmMaxDepth(maxDepth: number, options?: { global?: boolean }) { - return this.requestData<{ - maxDepth: number; - source: "default" | "env" | "global" | "inherited" | "chat"; - globalSaved: boolean; - globalError?: string; - }>({ + async setRlmMaxDepth(maxDepth: number, options?: { scope?: SettingsScope }): Promise { + const scope = options?.scope; + if (scope === "project" && !this.client.supportsServerCapability("config_scopes")) { + // An older daemon would write the global settings file instead of the + // repository one; fail loudly instead. + throw new Error( + "the daemon is running an older build without project config scope; restart the daemon and try again", + ); + } + return this.requestData({ type: "set_rlm_max_depth", activeSessionId: this.activeSessionId, maxDepth, - global: options?.global, + scope, + // Older daemons only understand the boolean flag. + global: scope === "global" ? true : undefined, }); } diff --git a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts index 552a910d9..041e2ef07 100644 --- a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts @@ -14,10 +14,11 @@ import type { AgentHeartbeatUpdateAction, } from "../../core/cron-jobs.js"; import type { ExtensionUIContext } from "../../core/extensions/types.js"; -import type { RefinementResult } from "../../core/refinement/index.js"; +import type { HarnessScope, RefinementResult } from "../../core/refinement/index.js"; import { type DeleteSessionFileResult, deleteSessionFile } from "../../core/session-file-actions.js"; import { SessionManager } from "../../core/session-manager.js"; import type { SessionStats } from "../../core/session-stats.js"; +import type { SettingsScope } from "../../core/settings-manager.js"; import { type SideQuestionRun, startSideQuestion } from "../../core/side-question.js"; import { waitForHeadlessCompletion } from "../headless-completion.js"; import { @@ -455,7 +456,7 @@ export class InProcessAgentConnection implements AgentConnection { } async refine( - options: { instructions?: string; rollbackId?: string; global?: boolean } = {}, + options: { instructions?: string; rollbackId?: string; scope?: HarnessScope } = {}, ): Promise { return this.session.refine(options); } @@ -525,7 +526,7 @@ export class InProcessAgentConnection implements AgentConnection { return this.session.getRlmMaxDepthStatus(); } - async setRlmMaxDepth(maxDepth: number, options?: { global?: boolean }) { + async setRlmMaxDepth(maxDepth: number, options?: { scope?: SettingsScope }) { return this.session.setRlmMaxDepth(maxDepth, options); } diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index ff34a9298..0e8fa9c04 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -16,11 +16,12 @@ import type { ReplayBuiltInToolName } from "../../core/extensions/index.js"; import type { InputSource } from "../../core/extensions/types.js"; import type { GoalState } from "../../core/goals.js"; import type { KernelSentAgentMessage } from "../../core/kernel/index.js"; -import type { RefinementResult } from "../../core/refinement/index.js"; +import type { HarnessScope, RefinementResult } from "../../core/refinement/index.js"; import type { RlmMaxDepthStatus, SetRlmMaxDepthResult } from "../../core/rlm-max-depth.js"; import type { SessionActionSnapshot } from "../../core/session-action-store.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import type { SessionStats } from "../../core/session-stats.js"; +import type { SettingsScope } from "../../core/settings-manager.js"; /** * Client-side interaction boundary consumed by InteractiveMode. @@ -708,7 +709,7 @@ export interface AgentConnection { setAutoRetryEnabled(enabled: boolean): Promise; compact(customInstructions?: string): Promise; - refine(options?: { instructions?: string; rollbackId?: string; global?: boolean }): Promise; + refine(options?: { instructions?: string; rollbackId?: string; scope?: HarnessScope }): Promise; abortCompaction(): Promise; abortBranchSummary(): Promise; abortRetry(): Promise; @@ -726,7 +727,7 @@ export interface AgentConnection { exportToJsonl(outputPath?: string): Promise; setSessionName(name: string): Promise; getRlmMaxDepthStatus(): Promise; - setRlmMaxDepth(maxDepth: number, options?: { global?: boolean }): Promise; + setRlmMaxDepth(maxDepth: number, options?: { scope?: SettingsScope }): Promise; renameSavedSession(sessionPath: string, name: string): Promise; deleteSavedSession(sessionPath: string): Promise; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index dfebcdf61..1eda48536 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -4352,7 +4352,8 @@ export class AgentDaemon { const result = await state.runtime.session.refine({ instructions: command.instructions, rollbackId: command.rollbackId, - global: command.global, + // Older clients only send the boolean flag. + scope: command.scope ?? (command.global ? "global" : undefined), }); return success(command.id, "refine", result); } @@ -4455,7 +4456,10 @@ export class AgentDaemon { case "set_rlm_max_depth": { const state = this.getSessionState(command.activeSessionId); - const result = await state.runtime.session.setRlmMaxDepth(command.maxDepth, { global: command.global }); + const result = await state.runtime.session.setRlmMaxDepth(command.maxDepth, { + // Older clients only send the boolean flag. + scope: command.scope ?? (command.global ? "global" : undefined), + }); return success(command.id, "set_rlm_max_depth", result); } diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index 02b1ad5e4..1d6f27e22 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -18,8 +18,10 @@ import type { } from "../../core/cron-jobs.js"; import type { InputSource } from "../../core/extensions/types.js"; import type { CustomMessage } from "../../core/messages.js"; +import type { HarnessScope } from "../../core/refinement/index.js"; import type { SessionCwdIssue } from "../../core/session-cwd.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; +import type { SettingsScope } from "../../core/settings-manager.js"; import type { AgentConnectionAgentStatus, AgentConnectionHeartbeat, @@ -57,8 +59,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 12 publishes idle-residency metadata on session summary rows. // Revision 13 narrows agent-origin reach and roster wire shapes to the nuclear family. // Revision 14 carries the client's monotonic telemetry opt-out on attach and reattach. -export const DAEMON_SCHEMA_REVISION = 14; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-14-816309b1cd50"; +// Revision 15 adds per-repository config scopes to refine and set_rlm_max_depth. +export const DAEMON_SCHEMA_REVISION = 15; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-15-b41c2c59c2c1"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -96,7 +99,11 @@ export type DaemonServerCapability = // identity). Clients must check before sending. | "transient_bash" | "session_input_admission" - | "prompt_admission_cancellation"; + | "prompt_admission_cancellation" + // The daemon honors per-project config scopes: `scope` on refine (local, + // project, or global harness store) and on set_rlm_max_depth (project or + // global settings file). Clients must check before requesting project scope. + | "config_scopes"; export type DaemonReplayStatus = "complete" | "partial" | "unavailable"; @@ -134,6 +141,7 @@ export const DAEMON_DEFAULT_SERVER_CAPABILITIES: readonly DaemonServerCapability "transient_bash", "session_input_admission", "prompt_admission_cancellation", + "config_scopes", ]; export interface DaemonRuntimeIdentity { @@ -557,7 +565,9 @@ export type DaemonCommand = activeSessionId: string; instructions?: string; rollbackId?: string; + /** Legacy pre-scope flag; daemons with "config_scopes" prefer `scope`. */ global?: boolean; + scope?: HarnessScope; } | { id?: string; type: "abort_compaction"; activeSessionId: string } | { id?: string; type: "abort_branch_summary"; activeSessionId: string } @@ -582,7 +592,15 @@ export type DaemonCommand = | { id?: string; type: "export_jsonl"; activeSessionId: string; outputPath?: string } | { id?: string; type: "set_session_name"; activeSessionId: string; name: string; workerToken?: string } | { id?: string; type: "get_rlm_max_depth_status"; activeSessionId: string } - | { id?: string; type: "set_rlm_max_depth"; activeSessionId: string; maxDepth: number; global?: boolean } + | { + id?: string; + type: "set_rlm_max_depth"; + activeSessionId: string; + maxDepth: number; + /** Legacy pre-scope flag; daemons with "config_scopes" prefer `scope`. */ + global?: boolean; + scope?: SettingsScope; + } | { id?: string; type: "rename_saved_session"; activeSessionId?: string; sessionPath: string; name: string } | { id?: string; type: "delete_saved_session"; activeSessionId?: string; sessionPath: string } | { id?: string; type: "get_session_context"; activeSessionId: string } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 3805cc422..7560b8600 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -38,6 +38,8 @@ export interface SettingsConfig { blockImages: boolean; enableSkillCommands: boolean; enableBuiltinSkills: boolean; + contextFiles: boolean; + globalContextFiles: boolean; steeringMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time"; transport: Transport; @@ -65,6 +67,8 @@ export interface SettingsCallbacks { onBlockImagesChange: (blocked: boolean) => void; onEnableSkillCommandsChange: (enabled: boolean) => void; onEnableBuiltinSkillsChange: (enabled: boolean) => void; + onContextFilesChange: (enabled: boolean) => void; + onGlobalContextFilesChange: (enabled: boolean) => void; onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onTransportChange: (transport: Transport) => void; @@ -383,8 +387,29 @@ export class SettingsSelectorComponent extends Container { values: ["true", "false"], }); - // Hardware cursor toggle (insert after builtin-skills) - const skillCommandsIndex = items.findIndex((item) => item.id === "builtin-skills"); + // Context file toggles (insert after builtin-skills) + const builtinSkillsIndex = items.findIndex((item) => item.id === "builtin-skills"); + items.splice( + builtinSkillsIndex + 1, + 0, + { + id: "context-files", + label: "AGENTS.md context", + description: "Load AGENTS.md and CLAUDE.md into the system prompt (takes effect after reload)", + currentValue: config.contextFiles ? "true" : "false", + values: ["true", "false"], + }, + { + id: "global-context-files", + label: "Global AGENTS.md", + description: "Include the agent dir's own AGENTS.md in every project (takes effect after reload)", + currentValue: config.globalContextFiles ? "true" : "false", + values: ["true", "false"], + }, + ); + + // Hardware cursor toggle (insert after the context file toggles) + const skillCommandsIndex = items.findIndex((item) => item.id === "global-context-files"); items.splice(skillCommandsIndex + 1, 0, { id: "show-hardware-cursor", label: "Show hardware cursor", @@ -473,6 +498,12 @@ export class SettingsSelectorComponent extends Container { case "builtin-skills": callbacks.onEnableBuiltinSkillsChange(newValue === "true"); break; + case "context-files": + callbacks.onContextFilesChange(newValue === "true"); + break; + case "global-context-files": + callbacks.onGlobalContextFilesChange(newValue === "true"); + break; case "steering-mode": callbacks.onSteeringModeChange(newValue as "all" | "one-at-a-time"); break; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0ec1d8bcd..59535d815 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -116,6 +116,7 @@ import { resolvePrimeInferencePostLoginModelAction } from "../../core/prime-infe import { parseCommandArgs } from "../../core/prompt-templates.js"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js"; import { SessionImportFileNotFoundError } from "../../core/session-import-errors.js"; +import type { SettingsScope } from "../../core/settings-manager.js"; import { parseSkillBlock } from "../../core/skill-blocks.js"; import { BUILTIN_SLASH_COMMANDS, @@ -7243,6 +7244,8 @@ export class InteractiveMode { blockImages: this.settingsManager.getBlockImages(), enableSkillCommands: this.settingsManager.getEnableSkillCommands(), enableBuiltinSkills: this.settingsManager.getEnableBuiltinSkills(), + contextFiles: this.settingsManager.getContextFiles().enabled, + globalContextFiles: this.settingsManager.getContextFiles().global, steeringMode: state.steeringMode, followUpMode: state.followUpMode, transport: this.settingsManager.getTransport(), @@ -7294,6 +7297,14 @@ export class InteractiveMode { this.settingsManager.setEnableBuiltinSkills(enabled); void this.handleReloadCommand(); }, + onContextFilesChange: (enabled) => { + this.settingsManager.setContextFilesOption("enabled", enabled); + void this.handleReloadCommand(); + }, + onGlobalContextFilesChange: (enabled) => { + this.settingsManager.setContextFilesOption("global", enabled); + void this.handleReloadCommand(); + }, onSteeringModeChange: (mode) => { this.patchConnectionState({ steeringMode: mode }); void this.agentConnection.setSteeringMode(mode).catch((error) => { @@ -8845,9 +8856,11 @@ export class InteractiveMode { return; } - const global = tokens[1] === "--global"; - if (tokens.length > (global ? 2 : 1) || !/^\d+$/.test(tokens[0] ?? "")) { - this.showWarning("Usage: /rlm-max-depth [ [--global]]"); + const scopeFlag = tokens[1]; + const scope: SettingsScope | undefined = + scopeFlag === "--global" ? "global" : scopeFlag === "--project" ? "project" : undefined; + if (tokens.length > (scope ? 2 : 1) || !/^\d+$/.test(tokens[0] ?? "")) { + this.showWarning("Usage: /rlm-max-depth [ [--project|--global]]"); return; } const maxDepth = Number(tokens[0]); @@ -8857,22 +8870,22 @@ export class InteractiveMode { } try { - const result = await this.agentConnection.setRlmMaxDepth(maxDepth, { global }); + const result = await this.agentConnection.setRlmMaxDepth(maxDepth, { scope }); + const savedSuffix = + result.savedScope === "project" && result.projectSaved + ? " and saved as project default" + : result.globalSaved + ? " and saved as global default" + : ""; this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( - new Text( - theme.fg( - "dim", - `RLM max depth set: ${result.maxDepth}${result.globalSaved ? " and saved as global default" : ""}`, - ), - 1, - 0, - ), + new Text(theme.fg("dim", `RLM max depth set: ${result.maxDepth}${savedSuffix}`), 1, 0), ); this.ui.requestRender(); - if (result.globalError) { + const saveError = result.globalError ?? result.projectError; + if (saveError) { this.showError( - `RLM max depth set for this chat, but the global default was not saved: ${result.globalError}`, + `RLM max depth set for this chat, but the ${result.savedScope} default was not saved: ${saveError}`, ); } } catch (error) { diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 7c3278816..211d54f38 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -16,7 +16,7 @@ import type { AgentHeartbeatManagementAction, AgentHeartbeatUpdateAction, } from "../../core/cron-jobs.js"; -import type { RefinementResult } from "../../core/refinement/index.js"; +import type { HarnessScope, RefinementResult } from "../../core/refinement/index.js"; import type { SessionStats } from "../../core/session-stats.js"; import type { AgentConnectionHeartbeat } from "../agent-connection/types.js"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; @@ -306,7 +306,7 @@ export class RpcClient { * Refine editable continual harness state. */ async refine( - options: { instructions?: string; rollbackId?: string; global?: boolean } = {}, + options: { instructions?: string; rollbackId?: string; scope?: HarnessScope } = {}, ): Promise { // Refinement runs an LLM pass that routinely exceeds the default 30s response // timeout, so use the same extended window as the daemon refine path. @@ -314,10 +314,10 @@ export class RpcClient { type: "refine"; instructions?: string; rollbackId?: string; - global?: boolean; + scope?: HarnessScope; }; - if (options.global !== undefined) { - command.global = options.global; + if (options.scope !== undefined) { + command.scope = options.scope; } const response = await this.send(command, REFINE_REQUEST_TIMEOUT_MS); return this.getData(response); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index a9d569048..b9a4b48f1 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -289,7 +289,7 @@ async function runRpcModeWithConnectionInternal( await connection.refine({ instructions: command.instructions, rollbackId: command.rollbackId, - global: command.global, + scope: command.scope, }), ); case "set_auto_compaction": diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 7cba4f8a9..082f6d356 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -17,7 +17,7 @@ import type { AgentHeartbeatUpdateAction, } from "../../core/cron-jobs.js"; import type { GoalState } from "../../core/goals.js"; -import type { RefinementResult } from "../../core/refinement/index.js"; +import type { HarnessScope, RefinementResult } from "../../core/refinement/index.js"; import type { SessionActionSnapshot } from "../../core/session-action-store.js"; import type { SessionStats } from "../../core/session-stats.js"; import type { AgentConnectionHeartbeat, AgentConnectionSourceInfo } from "../agent-connection/types.js"; @@ -52,7 +52,7 @@ export type RpcCommand = // Compaction | { id?: string; type: "compact"; customInstructions?: string } - | { id?: string; type: "refine"; instructions?: string; rollbackId?: string; global?: boolean } + | { id?: string; type: "refine"; instructions?: string; rollbackId?: string; scope?: HarnessScope } | { id?: string; type: "set_auto_compaction"; enabled: boolean } // Retry diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 7ff21e88c..05a968e19 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -398,6 +398,13 @@ class FakeDaemonClient { success: true, data: { ok: true, method: "trash" }, }; + case "set_rlm_max_depth": + return { + type: "response", + command: command.type, + success: true, + data: { maxDepth: command.maxDepth, source: "chat", globalSaved: false }, + }; case "refine": return { type: "response", @@ -934,6 +941,39 @@ describe("DaemonAgentConnection", () => { expect(sent?.previousTurns).toEqual([{ question: "What changed?", answer: "The parser." }]); }); + it("gates project config scope on the daemon capability", async () => { + const oldDaemonClient = new FakeDaemonClient(); + const oldConnection = new DaemonAgentConnection(asDaemonClient(oldDaemonClient), "active-original"); + + // Global and local refinement keep working on old daemons. + await oldConnection.refine({ scope: "global" }); + expect(oldDaemonClient.requests.at(-1)).toMatchObject({ type: "refine", scope: "global", global: true }); + + // A project refinement on an old daemon would silently write session-local state. + await expect(oldConnection.refine({ scope: "project" })).rejects.toThrow( + "older build without project harness scope", + ); + await expect(oldConnection.setRlmMaxDepth(2, { scope: "project" })).rejects.toThrow( + "older build without project config scope", + ); + expect(oldDaemonClient.requests).toHaveLength(1); + + const newDaemonClient = new FakeDaemonClient(); + newDaemonClient.serverCapabilities.add("config_scopes"); + const newConnection = new DaemonAgentConnection(asDaemonClient(newDaemonClient), "active-original"); + + await newConnection.refine({ scope: "project" }); + expect(newDaemonClient.requests.at(-1)).toMatchObject({ type: "refine", scope: "project" }); + expect(newDaemonClient.requests.at(-1)).not.toHaveProperty("global", true); + + await newConnection.setRlmMaxDepth(2, { scope: "project" }); + expect(newDaemonClient.requests.at(-1)).toMatchObject({ + type: "set_rlm_max_depth", + maxDepth: 2, + scope: "project", + }); + }); + it("gates transient bash on the daemon capability", async () => { const oldDaemonClient = new FakeDaemonClient(); const oldConnection = new DaemonAgentConnection(asDaemonClient(oldDaemonClient), "active-original"); diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index f066f02b1..4de77ec2f 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -1964,7 +1964,7 @@ describe("AgentSession rlm recursion", () => { const existingSettings = SettingsManager.create(tempDir, tempDir); const existing = createSession({ settingsManager: existingSettings }); - await expect(current.setRlmMaxDepth(4, { global: true })).resolves.toMatchObject({ + await expect(current.setRlmMaxDepth(4, { scope: "global" })).resolves.toMatchObject({ maxDepth: 4, source: "chat", globalSaved: true, @@ -1990,7 +1990,7 @@ describe("AgentSession rlm recursion", () => { }; const current = createSession({ settingsManager: SettingsManager.fromStorage(storage) }); - const result = await current.setRlmMaxDepth(5, { global: true }); + const result = await current.setRlmMaxDepth(5, { scope: "global" }); expect(result).toMatchObject({ maxDepth: 5, source: "chat", globalSaved: false }); expect(result.globalError).toContain("EROFS: read-only file system"); @@ -2028,7 +2028,7 @@ describe("AgentSession rlm recursion", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const current = createSession({ settingsManager }); - const result = await current.setRlmMaxDepth(5, { global: true }); + const result = await current.setRlmMaxDepth(5, { scope: "global" }); expect(result).toMatchObject({ maxDepth: 5, source: "chat", globalSaved: true }); expect(warn).toHaveBeenCalledWith("Warning: Earlier global settings write failed: stale global failure"); diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index b21443c56..6365d6f1a 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -6,6 +6,8 @@ import { detectInstallMethod, ENV_LEGACY_SESSION_DIR, ENV_SESSION_DIR, + getProjectConfigDir, + getProjectDir, getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, getSessionsDir, @@ -446,3 +448,47 @@ describe("session paths", () => { expect(sessionDir).toBe(sessionRoot); }); }); + +describe("project paths", () => { + test("uses the nearest ancestor holding a project config dir", () => { + tempDir = mkdtempSync(join(tmpdir(), "pi-project-root-")); + const projectDir = join(tempDir, "repo"); + const nested = join(projectDir, "packages", "app"); + mkdirSync(join(projectDir, ".prime", "agent"), { recursive: true }); + mkdirSync(nested, { recursive: true }); + + expect(getProjectDir(nested)).toBe(projectDir); + expect(getProjectConfigDir(nested)).toBe(join(projectDir, ".prime", "agent")); + }); + + test("falls back to the enclosing repository root", () => { + tempDir = mkdtempSync(join(tmpdir(), "pi-project-root-")); + const projectDir = join(tempDir, "repo"); + const nested = join(projectDir, "src"); + mkdirSync(join(projectDir, ".git"), { recursive: true }); + mkdirSync(nested, { recursive: true }); + + expect(getProjectDir(nested)).toBe(projectDir); + }); + + test("prefers the repository root over a config dir outside it", () => { + tempDir = mkdtempSync(join(tmpdir(), "pi-project-root-")); + const projectDir = join(tempDir, "repo"); + mkdirSync(join(tempDir, ".prime", "agent"), { recursive: true }); + mkdirSync(join(projectDir, ".git"), { recursive: true }); + + expect(getProjectDir(projectDir)).toBe(projectDir); + }); + + test("falls back to the working directory outside a repository", () => { + tempDir = mkdtempSync(join(tmpdir(), "pi-project-root-")); + const workDir = join(tempDir, "loose"); + mkdirSync(workDir, { recursive: true }); + + expect(getProjectDir(workDir)).toBe(workDir); + }); + + test("never treats the home directory as a project", () => { + expect(getProjectDir(homedir())).toBe(homedir()); + }); +}); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index e8e6aed07..92808eed8 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -8335,7 +8335,17 @@ describe("daemon mode helpers", () => { global: true, }), ).resolves.toMatchObject({ success: true, data: { maxDepth: 3, globalSaved: true } }); - expect(setRlmMaxDepth).toHaveBeenCalledWith(3, { global: true }); + // Older clients send only the boolean flag; the daemon maps it onto the settings scope. + expect(setRlmMaxDepth).toHaveBeenCalledWith(3, { scope: "global" }); + await expect( + internals.handleCommand(client, { + type: "set_rlm_max_depth", + activeSessionId: state.activeSessionId, + maxDepth: 3, + scope: "project", + }), + ).resolves.toMatchObject({ success: true }); + expect(setRlmMaxDepth).toHaveBeenLastCalledWith(3, { scope: "project" }); }); it.each([ diff --git a/packages/coding-agent/test/kernel-bootstrap.test.ts b/packages/coding-agent/test/kernel-bootstrap.test.ts index eeb2fc0d1..40e0a8c4d 100644 --- a/packages/coding-agent/test/kernel-bootstrap.test.ts +++ b/packages/coding-agent/test/kernel-bootstrap.test.ts @@ -29,7 +29,7 @@ function writeBootstrapVersion(venv: string, pythonSkills: readonly KernelPython writeFileSync( join(venv, ".bootstrap-version"), `${JSON.stringify({ - schema: 8, + schema: 9, ipykernel: "ipykernel", runtime: runtimeIdentity, snapshot: "dill", @@ -193,7 +193,7 @@ describe("kernel bootstrap", () => { } const version = JSON.parse(readFileSync(join(venv, ".bootstrap-version"), "utf8")); expect(version).toEqual({ - schema: 8, + schema: 9, ipykernel: "ipykernel", runtime: runtimeIdentity, snapshot: "dill", @@ -436,7 +436,7 @@ dependencies = ["httpx"] writeFileSync( join(venv, ".bootstrap-version"), `${JSON.stringify({ - schema: 8, + schema: 9, ipykernel: "ipykernel", runtime: "sha256:stale", snapshot: "dill", diff --git a/packages/coding-agent/test/refinement.test.ts b/packages/coding-agent/test/refinement.test.ts index 9efbdd060..3dd81b09d 100644 --- a/packages/coding-agent/test/refinement.test.ts +++ b/packages/coding-agent/test/refinement.test.ts @@ -6,7 +6,7 @@ import type * as PiAi from "@earendil-works/pi-ai"; import type { AssistantMessage, Model } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - appendGlobalRefinement, + appendSharedRefinement, applyRefinementProposal, formatHarnessStateForPrompt, getGlobalHarnessStateDir, @@ -16,8 +16,8 @@ import { getRefinementHistoryPath, type HarnessState, inferRefinementResultScope, - loadGlobalRefinementHistory, loadHarnessState, + loadSharedRefinementHistory, mergeHarnessStates, mergeRefinementHistory, planRefinement, @@ -592,7 +592,7 @@ describe("harness refinement", () => { { id: "refine_local", scope: "local" }, ); - const merged = mergeHarnessStates(globalState, localState); + const merged = mergeHarnessStates({ global: globalState, local: localState }); expect(merged.entries.memory.shared.content).toBe("Global content."); expect(merged.entries.memory.shared.scope).toBe("global"); @@ -627,7 +627,7 @@ describe("harness refinement", () => { { id: "refine_local_in_global_file", scope: "local" }, ); - const merged = mergeHarnessStates(globalState); + const merged = mergeHarnessStates({ global: globalState }); expect(merged.entries.memory.session_note.scope).toBe("local"); }); @@ -1017,17 +1017,20 @@ describe("harness refinement", () => { expect(completeSimpleMock).toHaveBeenCalledTimes(1); expect(completeSimpleMock.mock.calls[0][1]).toMatchObject({ - systemPrompt: expect.stringContaining("The default editable continual harness store is local"), + systemPrompt: expect.stringContaining("The default store is local"), }); expect(completeSimpleMock.mock.calls[0][1]).toMatchObject({ - systemPrompt: expect.stringContaining("A caller may explicitly request global refinement"), + systemPrompt: expect.stringContaining("Project edits must be repository-specific"), + }); + expect(completeSimpleMock.mock.calls[0][1]).toMatchObject({ + systemPrompt: expect.stringContaining("Global edits must be stable cross-session lessons"), }); expect(completeSimpleMock.mock.calls[0][1]).toMatchObject({ systemPrompt: expect.stringContaining("Always use the bare id (no prefix) in edits"), }); expect(completeSimpleMock.mock.calls[0][1]).toMatchObject({ systemPrompt: expect.stringContaining( - "During a local refinement, global entries are read-only context: never propose update or delete edits for them", + "Entries from the other scopes are read-only context: never propose update or delete edits for them", ), }); // Budget is derived from the model (8192) rather than a fixed literal. @@ -1211,15 +1214,15 @@ describe("global refinement history", () => { it("appends and reloads refinement results across calls", () => { const dir = makeTempDir(); - expect(loadGlobalRefinementHistory(dir)).toEqual([]); + expect(loadSharedRefinementHistory(dir)).toEqual([]); const first = sampleResult("refine_1"); const second = sampleResult("refine_2"); - const historyPath = appendGlobalRefinement(dir, first); - appendGlobalRefinement(dir, second); + const historyPath = appendSharedRefinement(dir, first); + appendSharedRefinement(dir, second); expect(historyPath).toBe(getRefinementHistoryPath(dir)); - expect(loadGlobalRefinementHistory(dir)).toEqual([ + expect(loadSharedRefinementHistory(dir)).toEqual([ { ...first, scope: "global" }, { ...second, scope: "global" }, ]); @@ -1235,7 +1238,7 @@ describe("global refinement history", () => { "utf8", ); - expect(loadGlobalRefinementHistory(dir)[0]).toMatchObject({ id: "refine_legacy_global", scope: "global" }); + expect(loadSharedRefinementHistory(dir)[0]).toMatchObject({ id: "refine_legacy_global", scope: "global" }); }); it("writes inferred legacy history scope back onto loaded results", () => { @@ -1270,7 +1273,7 @@ describe("global refinement history", () => { }); appendFileSync(getRefinementHistoryPath(dir), `${JSON.stringify(legacy)}\n`, "utf8"); - expect(loadGlobalRefinementHistory(dir)[0]).toMatchObject({ + expect(loadSharedRefinementHistory(dir)[0]).toMatchObject({ id: "refine_legacy_inferred", scope: "global", }); @@ -1289,12 +1292,12 @@ describe("global refinement history", () => { it("skips malformed history lines without throwing", () => { const dir = makeTempDir(); const valid = sampleResult("refine_valid"); - appendGlobalRefinement(dir, valid); + appendSharedRefinement(dir, valid); // Corrupt append: a non-JSON line and a JSON object that is not a refinement result. appendFileSync(getRefinementHistoryPath(dir), "not json\n", "utf8"); appendFileSync(getRefinementHistoryPath(dir), `${JSON.stringify({ id: "x" })}\n`, "utf8"); - expect(loadGlobalRefinementHistory(dir)).toEqual([{ ...valid, scope: "global" }]); + expect(loadSharedRefinementHistory(dir)).toEqual([{ ...valid, scope: "global" }]); }); it("merges global and session history, preferring session entries by id", () => { @@ -1350,7 +1353,7 @@ describe("global refinement history", () => { const request = completeSimpleMock.mock.calls[0][1]; const userPrompt = request.messages[0].content[0].text; expect(userPrompt).toContain("Requested refinement scope: local"); - expect(userPrompt).toContain("Global entries in the overview are read-only context"); + expect(userPrompt).toContain("Project and global entries in the overview are read-only context"); expect(request.systemPrompt).toContain('handle = await rlm("sub-task")'); expect(request.systemPrompt).toContain("never the child's answer"); expect(request.systemPrompt).toContain('receiver_role="parent"'); @@ -1385,7 +1388,7 @@ describe("global refinement history", () => { [], createRefineModel(false), "api-key", - { global: true }, + { scope: "global" }, ); const userPrompt = completeSimpleMock.mock.calls[0][1].messages[0].content[0].text; @@ -1393,6 +1396,33 @@ describe("global refinement history", () => { expect(userPrompt).toContain("Do not persist session-only progress"); }); + it("adds project-only scope policy when planning a project refinement", async () => { + const state = loadHarnessState(makeTempDir(), "project"); + completeSimpleMock.mockResolvedValueOnce( + assistantText( + JSON.stringify({ + summary: "No project edit", + rationale: "No repository-specific lesson.", + expectedOutcome: "No change.", + edits: [], + }), + ), + ); + + await planRefinement( + [{ role: "user", content: "remember this for this repo", timestamp: Date.now() } satisfies AgentMessage], + state, + [], + createRefineModel(false), + "api-key", + { scope: "project" }, + ); + + const userPrompt = completeSimpleMock.mock.calls[0][1].messages[0].content[0].text; + expect(userPrompt).toContain("Requested refinement scope: project"); + expect(userPrompt).toContain("Do not persist session-only progress or user-wide preferences here"); + }); + it("plans a rollback without mutating harness state", async () => { const dir = makeTempDir(); const state = loadHarnessState(dir); @@ -1433,14 +1463,14 @@ describe("global refinement history", () => { { id: "refine_session_a" }, ); applied.harnessStatePath = saveHarnessState(dir, sessionAState); - appendGlobalRefinement(dir, applied); + appendSharedRefinement(dir, applied); // A fresh session loads the global state and the global history (its own session // has no record of refine_session_a) and can still roll it back. const sessionBState = loadHarnessState(dir); expect(sessionBState.entries.memory.session_a_memory).toBeDefined(); - const globalHistory = mergeRefinementHistory(loadGlobalRefinementHistory(dir), getRefinementHistory([])); + const globalHistory = mergeRefinementHistory(loadSharedRefinementHistory(dir), getRefinementHistory([])); const rollback = await refineHarness([], sessionBState, globalHistory, {} as never, "api-key", { rollbackId: "refine_session_a", }); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index accdb4355..92c88cd1a 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -460,6 +460,74 @@ Content`, }); }); + describe("context files", () => { + function seedContextFiles(): void { + mkdirSync(cwd, { recursive: true }); + mkdirSync(join(cwd, ".git"), { recursive: true }); + writeFileSync(join(agentDir, "AGENTS.md"), "Global instructions"); + writeFileSync(join(tempDir, "AGENTS.md"), "Ancestor instructions"); + writeFileSync(join(cwd, "AGENTS.md"), "Project instructions"); + } + + function settingsWith(contextFiles: Record): SettingsManager { + return SettingsManager.inMemory({ contextFiles }); + } + + it("loads global, ancestor, and project context files by default", async () => { + seedContextFiles(); + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + expect(loader.getAgentsFiles().agentsFiles.map((file) => file.content)).toEqual([ + "Global instructions", + "Ancestor instructions", + "Project instructions", + ]); + }); + + it("drops the global context file when contextFiles.global is false", async () => { + seedContextFiles(); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager: settingsWith({ global: false }), + }); + await loader.reload(); + + expect(loader.getAgentsFiles().agentsFiles.map((file) => file.content)).toEqual([ + "Ancestor instructions", + "Project instructions", + ]); + }); + + it("stops at the project root when contextFiles.ancestors is false", async () => { + seedContextFiles(); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager: settingsWith({ ancestors: false }), + }); + await loader.reload(); + + expect(loader.getAgentsFiles().agentsFiles.map((file) => file.content)).toEqual([ + "Global instructions", + "Project instructions", + ]); + }); + + it("loads nothing when contextFiles.enabled is false", async () => { + seedContextFiles(); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager: settingsWith({ enabled: false }), + }); + await loader.reload(); + + expect(loader.getAgentsFiles().agentsFiles).toEqual([]); + }); + }); + describe("bundled skills", () => { it("should load the bundled websearch skill by default", async () => { const loader = new DefaultResourceLoader({ cwd, agentDir }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 9a0955d86..24f0a6f39 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -16,6 +16,9 @@ describe("SettingsManager", () => { } mkdirSync(agentDir, { recursive: true }); mkdirSync(join(projectDir, ".prime", "agent"), { recursive: true }); + // Project settings resolve against the nearest repository root, so mark the + // temp project as one instead of inheriting this repository's root. + mkdirSync(join(projectDir, ".git"), { recursive: true }); }); afterEach(() => { @@ -578,4 +581,55 @@ describe("SettingsManager", () => { expect(manager.getTelemetryEnabled()).toBe(false); }); }); + describe("contextFiles", () => { + it("defaults every toggle to enabled", () => { + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getContextFiles()).toEqual({ enabled: true, global: true, ancestors: true }); + }); + + it("lets project settings override the global toggle", async () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ contextFiles: { global: true } })); + writeFileSync( + join(projectDir, ".prime", "agent", "settings.json"), + JSON.stringify({ contextFiles: { global: false } }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getContextFiles()).toEqual({ enabled: true, global: false, ancestors: true }); + await manager.flush(); + }); + + it("writes one toggle per scope without dropping the others", async () => { + const manager = SettingsManager.create(projectDir, agentDir); + manager.setContextFilesOption("global", false); + manager.setContextFilesOption("ancestors", false, "project"); + await manager.flush(); + + expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")).contextFiles).toEqual({ + global: false, + }); + expect( + JSON.parse(readFileSync(join(projectDir, ".prime", "agent", "settings.json"), "utf-8")).contextFiles, + ).toEqual({ ancestors: false }); + expect(manager.getContextFiles()).toEqual({ enabled: true, global: false, ancestors: false }); + }); + }); + + describe("rlmMaxDepth", () => { + it("reads and writes the global and project defaults independently", async () => { + const manager = SettingsManager.create(projectDir, agentDir); + manager.setRlmMaxDepth(2); + manager.setRlmMaxDepth(4, "project"); + await manager.flush(); + + expect(manager.getRlmMaxDepth()).toBe(2); + expect(manager.getRlmMaxDepth("project")).toBe(4); + expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")).rlmMaxDepth).toBe(2); + expect( + JSON.parse(readFileSync(join(projectDir, ".prime", "agent", "settings.json"), "utf-8")).rlmMaxDepth, + ).toBe(4); + }); + }); }); diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index 81b19973c..0d15657cf 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -16,6 +16,8 @@ const config: SettingsConfig = { blockImages: false, enableSkillCommands: true, enableBuiltinSkills: true, + contextFiles: true, + globalContextFiles: true, steeringMode: "one-at-a-time", followUpMode: "one-at-a-time", transport: "sse", @@ -43,6 +45,8 @@ const callbacks: SettingsCallbacks = { onBlockImagesChange: () => {}, onEnableSkillCommandsChange: () => {}, onEnableBuiltinSkillsChange: () => {}, + onContextFilesChange: () => {}, + onGlobalContextFilesChange: () => {}, onSteeringModeChange: () => {}, onFollowUpModeChange: () => {}, onTransportChange: () => {}, diff --git a/packages/coding-agent/test/slash-commands.test.ts b/packages/coding-agent/test/slash-commands.test.ts index d40643955..62244a949 100644 --- a/packages/coding-agent/test/slash-commands.test.ts +++ b/packages/coding-agent/test/slash-commands.test.ts @@ -217,25 +217,40 @@ describe("session slash commands", () => { } }); - test("parses refine rollback ids and --global placement without consuming instruction text", () => { - expect(parseRefineCommandOptions("rollback refine_123")).toEqual({ rollbackId: "refine_123", global: false }); + test("parses refine rollback ids and scope flag placement without consuming instruction text", () => { + expect(parseRefineCommandOptions("rollback refine_123")).toEqual({ + rollbackId: "refine_123", + scope: undefined, + }); expect(parseRefineCommandOptions("rollback refine_456 --global")).toEqual({ rollbackId: "refine_456", - global: true, + scope: "global", + }); + expect(parseRefineCommandOptions("rollback refine_457 --project")).toEqual({ + rollbackId: "refine_457", + scope: "project", }); expect(parseRefineCommandOptions("--global rollback refine_789")).toEqual({ rollbackId: "refine_789", - global: true, + scope: "global", + }); + expect(parseRefineCommandOptions("--project rollback refine_790")).toEqual({ + rollbackId: "refine_790", + scope: "project", }); expect(parseRefineCommandOptions("--global focus on validation")).toEqual({ instructions: "focus on validation", - global: true, + scope: "global", + }); + expect(parseRefineCommandOptions("--project focus on validation")).toEqual({ + instructions: "focus on validation", + scope: "project", }); expect(parseRefineCommandOptions("update docs to explain --global")).toEqual({ instructions: "update docs to explain --global", - global: false, + scope: undefined, }); - for (const args of ["rollback", "rollback --global"]) { + for (const args of ["rollback", "rollback --global", "rollback --project"]) { expect(() => parseRefineCommandOptions(args)).toThrow("Usage: /refine rollback "); } }); diff --git a/packages/coding-agent/test/suite/acp-features.test.ts b/packages/coding-agent/test/suite/acp-features.test.ts index 7b51917cf..88b00ce5a 100644 --- a/packages/coding-agent/test/suite/acp-features.test.ts +++ b/packages/coding-agent/test/suite/acp-features.test.ts @@ -450,7 +450,7 @@ describe("ACP mode preserves prime-agent features", () => { sessionId: fixture.sessionId, prompt: [{ type: "text", text: "remember this" }], }); - await harness.session.refine({ global: true }); + await harness.session.refine({ scope: "global" }); await waitFor(() => fixture.metaOf("refinement").length > 0); const refinements = fixture.metaOf("refinement"); diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index e61b5fec1..82bdfefdf 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -18,10 +18,12 @@ import { getGlobalHarnessStateDir, getHarnessStatePath, getLocalHarnessStateDir, + getProjectHarnessStateDir, type HarnessEntry, - loadGlobalRefinementHistory, loadHarnessState, + loadSharedRefinementHistory, type RefinementResult, + type RefineOptions, saveHarnessState, } from "../../src/core/refinement/index.js"; import { parseSessionSlashCommand } from "../../src/core/slash-commands.js"; @@ -135,7 +137,11 @@ describe("AgentSession queue characterization", () => { instructions: "capture the durable lesson", }, expectedReviewContext: { reason: "turn_interval", turnsSinceLastReview: 2 }, - refineFragments: ["capture the durable lesson", "local harness entries", "Do not promote anything global"], + refineFragments: [ + "capture the durable lesson", + "local harness entries", + "Do not promote anything to the project or global store", + ], turnsAfter: 0, compactPendingAfter: undefined as boolean | undefined, scheduleCalledWith: undefined as AutoRefineReason | undefined, @@ -854,7 +860,7 @@ describe("AgentSession queue characterization", () => { seedGlobal: false, seedLocal: true, editId: "global:shared", - refineOptions: { instructions: "update local memory" }, + refineOptions: { instructions: "update local memory" } as RefineOptions, updatedContent: "Updated local content", expectLocalContent: "Updated local content" as string | undefined, expectGlobalContent: undefined as string | undefined, @@ -864,7 +870,7 @@ describe("AgentSession queue characterization", () => { seedGlobal: true, seedLocal: false, editId: "global:shared", - refineOptions: { instructions: "update the global shared memory", global: true }, + refineOptions: { instructions: "update the global shared memory", scope: "global" } as RefineOptions, updatedContent: "Updated global content", expectLocalContent: undefined as string | undefined, expectGlobalContent: "Updated global content" as string | undefined, @@ -936,6 +942,52 @@ describe("AgentSession queue characterization", () => { }, ); + it("applies a project refinement to the repository harness store", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + const globalDir = getGlobalHarnessStateDir(); + const projectDir = getProjectHarnessStateDir(harness.tempDir); + const localDir = getLocalHarnessStateDir(harness.sessionManager.getSessionArtifactDir())!; + harness.setResponses([ + fauxAssistantMessage( + JSON.stringify({ + summary: "Record the repository test command", + rationale: "The command was rediscovered twice.", + expectedOutcome: "Future sessions in this repository reuse it.", + edits: [ + { + action: "create", + kind: "memory", + id: "test_command", + title: "Test command", + content: "npm run check", + }, + ], + }), + ), + ]); + + const result = await harness.session.refine({ scope: "project" }); + + expect(result.scope).toBe("project"); + expect(result.harnessStatePath).toBe(getHarnessStatePath(projectDir)); + expect(loadHarnessState(projectDir, "project").entries.memory.test_command.scope).toBe("project"); + expect(loadHarnessState(localDir, "local").entries.memory.test_command).toBeUndefined(); + expect(loadHarnessState(globalDir, "global").entries.memory.test_command).toBeUndefined(); + // Project refinements are replayable from a later session in the same repository. + expect(loadSharedRefinementHistory(projectDir, "project").map((item) => item.id)).toEqual([result.id]); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + it("rolls back copied local refinement history against the original local harness state", async () => { const original = await createAutoRefineHarness(); const branched = await createAutoRefineHarness(); @@ -1256,7 +1308,7 @@ describe("AgentSession queue characterization", () => { const stored = JSON.parse(readFileSync(getHarnessStatePath(globalDir), "utf8")); expect(stored.entries.memory.legacy_target).toBeUndefined(); expect(stored.entries.memory.keep_me.scope).toBe("global"); - const rollbackRecord = loadGlobalRefinementHistory(globalDir).find( + const rollbackRecord = loadSharedRefinementHistory(globalDir).find( (item) => item.rollbackOf === "refine_legacy", ); expect(rollbackRecord).toBeDefined(); @@ -2748,13 +2800,15 @@ describe("AgentSession queue characterization", () => { const refine = vi.spyOn(harness.session, "refine").mockResolvedValue(emptyRefinementResult()); for (const [command, options] of [ - ["/refine rollback refine_123", { rollbackId: "refine_123", global: false }], - ["/refine rollback refine_456 --global", { rollbackId: "refine_456", global: true }], - ["/refine --global rollback refine_789", { rollbackId: "refine_789", global: true }], - ["/refine --global focus on validation", { instructions: "focus on validation", global: true }], + ["/refine rollback refine_123", { rollbackId: "refine_123", scope: undefined }], + ["/refine rollback refine_456 --global", { rollbackId: "refine_456", scope: "global" }], + ["/refine --global rollback refine_789", { rollbackId: "refine_789", scope: "global" }], + ["/refine --project rollback refine_790", { rollbackId: "refine_790", scope: "project" }], + ["/refine --global focus on validation", { instructions: "focus on validation", scope: "global" }], + ["/refine --project focus on validation", { instructions: "focus on validation", scope: "project" }], [ "/refine update docs to explain --global", - { instructions: "update docs to explain --global", global: false }, + { instructions: "update docs to explain --global", scope: undefined }, ], ] as const) { await harness.session.prompt(command); diff --git a/prime-agent-runtime/src/rlm/__init__.py b/prime-agent-runtime/src/rlm/__init__.py index 0c746fec9..6da5e7870 100644 --- a/prime-agent-runtime/src/rlm/__init__.py +++ b/prime-agent-runtime/src/rlm/__init__.py @@ -257,7 +257,7 @@ def _resolve(self) -> HarnessState: in_memory=True, local_write_error=( f"{exc} This session has no persistent local harness store; " - "pass global_=True to persist across sessions." + 'pass scope="project" or scope="global" to persist across sessions.' ), ) return _HarnessProxy._unpersisted diff --git a/prime-agent-runtime/src/rlm/harness.py b/prime-agent-runtime/src/rlm/harness.py index a85678aa7..0992ffe18 100644 --- a/prime-agent-runtime/src/rlm/harness.py +++ b/prime-agent-runtime/src/rlm/harness.py @@ -16,11 +16,13 @@ from typing import Any, Literal HarnessKind = Literal["prompt", "memory", "skill", "subagent"] -HarnessScope = Literal["local", "global"] +HarnessScope = Literal["local", "project", "global"] _DEFAULT_FILE_NAME = "harness_state.json" _DEFAULT_HARNESS_DIR_NAME = "harness" +_CONFIG_DIR_PARTS = (".prime", "agent") _KINDS: tuple[HarnessKind, ...] = ("prompt", "memory", "skill", "subagent") +_SCOPES: tuple[HarnessScope, ...] = ("local", "project", "global") _state_cache: dict[tuple[Path, HarnessScope], "HarnessState"] = {} @@ -43,28 +45,39 @@ def _agent_dir() -> Path: return Path(raw).expanduser().resolve() -def _resolve_global_flag(global_: bool = False, extra: dict[str, Any] | None = None) -> bool: +def _project_dir() -> Path: + """Nearest ancestor of the cwd holding a project config dir, else a repo root, else the cwd.""" + cwd = Path.cwd().resolve() + repo_root: Path | None = None + for candidate in (cwd, *cwd.parents): + if candidate.joinpath(*_CONFIG_DIR_PARTS).is_dir(): + return candidate + if repo_root is None and (candidate / ".git").exists(): + repo_root = candidate + return repo_root or cwd + + +def _normalize_scope(scope: HarnessScope | None, extra: dict[str, Any] | None = None) -> HarnessScope | None: extra = dict(extra or {}) - if "global" in extra: - value = extra.pop("global") - if not isinstance(value, bool): - raise TypeError(f"global must be a bool, got {type(value).__name__}") - global_ = value if extra: unexpected = next(iter(extra)) raise TypeError(f"unexpected keyword argument {unexpected!r}") - return bool(global_) + if scope is None: + return None + if scope not in _SCOPES: + raise ValueError(f"scope must be one of {_SCOPES}, got {scope!r}") + return scope -def _strip_scope_prefix(id: str | None, global_: bool) -> tuple[str | None, bool]: - # overview() displays entries as [local:id]/[global:id]; accept those ids - # verbatim. A global: prefix routes to the global store unless the caller - # already forced a scope via global_. +def _strip_scope_prefix(id: str | None, scope: HarnessScope | None) -> tuple[str | None, HarnessScope | None]: + # overview() displays entries as [local:id]/[project:id]/[global:id]; accept + # those ids verbatim. The prefix routes to that store unless the caller + # already forced a scope explicitly. if isinstance(id, str): - scope, sep, rest = id.partition(":") - if sep and rest and scope in ("local", "global"): - return rest, global_ or scope == "global" - return id, global_ + prefix, sep, rest = id.partition(":") + if sep and rest and prefix in _SCOPES: + return rest, scope or prefix # type: ignore[return-value] + return id, scope def _env_dir(name: str) -> str | None: @@ -74,19 +87,26 @@ def _env_dir(name: str) -> str | None: return value or None -def _state_file(state_dir: str | Path | None = None, *, global_: bool = False) -> Path: +def _state_file(state_dir: str | Path | None = None, *, scope: HarnessScope = "local") -> Path: root: str | Path | None = state_dir if root is None: - root = _env_dir("RLM_GLOBAL_HARNESS_STATE_DIR") if global_ else _env_dir("RLM_HARNESS_STATE_DIR") - if root is None and not global_ and (session_dir := _env_dir("RLM_SESSION_DIR")): + if scope == "global": + root = _env_dir("RLM_GLOBAL_HARNESS_STATE_DIR") + elif scope == "project": + root = _env_dir("RLM_PROJECT_HARNESS_STATE_DIR") + else: + root = _env_dir("RLM_HARNESS_STATE_DIR") + if root is None and scope == "local" and (session_dir := _env_dir("RLM_SESSION_DIR")): root = Path(session_dir) / _DEFAULT_HARNESS_DIR_NAME - if root is None and not global_: + if root is None and scope == "local": raise RuntimeError( "Local harness state requires RLM_HARNESS_STATE_DIR or RLM_SESSION_DIR. " - "Use get_harness_state(global_=True) for global state." + 'Use get_harness_state(scope="project") or get_harness_state(scope="global") instead.' ) if root: return Path(root).expanduser().resolve() / _DEFAULT_FILE_NAME + if scope == "project": + return _project_dir().joinpath(*_CONFIG_DIR_PARTS, _DEFAULT_HARNESS_DIR_NAME, _DEFAULT_FILE_NAME) return _agent_dir() / _DEFAULT_HARNESS_DIR_NAME / _DEFAULT_FILE_NAME @@ -155,17 +175,15 @@ def __init__( self.file_path: Path | None = None else: self.file_path = ( - Path(file_path).expanduser().resolve() - if file_path - else _state_file(global_=(scope == "global")) + Path(file_path).expanduser().resolve() if file_path else _state_file(scope=scope) ) self.scope: HarnessScope = scope # When set, local mutations raise instead of vanishing into a volatile - # store; reads and global_=True delegation keep working. + # store; reads and cross-scope delegation keep working. self._local_write_error = local_write_error self.entries: dict[HarnessKind, dict[str, HarnessEntry]] = {kind: {} for kind in _KINDS} self.refinements: list[RefinementEvent] = [] - self._global_target_state_dir: Path | None = None + self._delegate_state_dir: Path | None = None # mtime of the file as of the last load/save, used to detect out-of-process # writes (e.g. the host `/refine` command) and avoid clobbering them. self._loaded_mtime: int | None = None @@ -230,7 +248,7 @@ def load(self) -> "HarnessState": continue if not isinstance(entry_data.get("path"), str): entry_data["path"] = "general" - if entry_data.get("scope") not in ("local", "global"): + if entry_data.get("scope") not in _SCOPES: entry_data["scope"] = self.scope if not isinstance(entry_data.get("source"), str): entry_data["source"] = "agent" @@ -273,10 +291,11 @@ def load(self) -> "HarnessState": self._loaded_mtime = mtime return self - def _global_target(self, global_: bool, extra: dict[str, Any] | None = None) -> "HarnessState | None": - if not _resolve_global_flag(global_, extra): + def _scope_target(self, scope: HarnessScope | None, extra: dict[str, Any] | None = None) -> "HarnessState | None": + scope = _normalize_scope(scope, extra) + if scope is None or scope == self.scope: return None - target = get_harness_state(state_dir=self._global_target_state_dir, global_=True) + target = get_harness_state(state_dir=self._delegate_state_dir, scope=scope) if self.file_path is not None and target.file_path == self.file_path and target.scope == self.scope: return None return target @@ -311,11 +330,11 @@ def upsert( arguments: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, source: str = "agent", - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: - id, global_ = _strip_scope_prefix(id, global_) - if target := self._global_target(global_, kwargs): + id, scope = _strip_scope_prefix(id, scope) + if target := self._scope_target(scope, kwargs): return target.upsert( kind, title, @@ -399,18 +418,18 @@ def _upsert( self.save() return entry - def get(self, kind: HarnessKind, id: str, *, global_: bool = False, **kwargs: Any) -> HarnessEntry | None: - id, global_ = _strip_scope_prefix(id, global_) - if target := self._global_target(global_, kwargs): + def get(self, kind: HarnessKind, id: str, *, scope: HarnessScope | None = None, **kwargs: Any) -> HarnessEntry | None: + id, scope = _strip_scope_prefix(id, scope) + if target := self._scope_target(scope, kwargs): return target.get(kind, id) self._sync_from_disk() if kind not in self.entries: raise ValueError(f"unknown harness kind {kind!r}; expected one of {_KINDS}") return self.entries[kind].get(id) - def delete(self, kind: HarnessKind, id: str, *, global_: bool = False, **kwargs: Any) -> bool: - id, global_ = _strip_scope_prefix(id, global_) - if target := self._global_target(global_, kwargs): + def delete(self, kind: HarnessKind, id: str, *, scope: HarnessScope | None = None, **kwargs: Any) -> bool: + id, scope = _strip_scope_prefix(id, scope) + if target := self._scope_target(scope, kwargs): return target.delete(kind, id) self._ensure_local_writable() self._sync_from_disk() @@ -422,8 +441,8 @@ def delete(self, kind: HarnessKind, id: str, *, global_: bool = False, **kwargs: self.save() return True - def list(self, kind: HarnessKind | None = None, *, global_: bool = False, **kwargs: Any) -> list[HarnessEntry]: - if target := self._global_target(global_, kwargs): + def list(self, kind: HarnessKind | None = None, *, scope: HarnessScope | None = None, **kwargs: Any) -> list[HarnessEntry]: + if target := self._scope_target(scope, kwargs): return target.list(kind) self._sync_from_disk() kinds = [kind] if kind else list(_KINDS) @@ -446,11 +465,11 @@ def create( arguments: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, source: str = "agent", - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: - id, global_ = _strip_scope_prefix(id, global_) - if target := self._global_target(global_, kwargs): + id, scope = _strip_scope_prefix(id, scope) + if target := self._scope_target(scope, kwargs): return target.create( kind, title, @@ -493,11 +512,11 @@ def update( arguments: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, source: str = "agent", - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: - id, global_ = _strip_scope_prefix(id, global_) - if target := self._global_target(global_, kwargs): + id, scope = _strip_scope_prefix(id, scope) + if target := self._scope_target(scope, kwargs): return target.update( kind, id, @@ -535,10 +554,10 @@ def create_memory( id: str | None = None, path: str = "general", metadata: dict[str, Any] | None = None, - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: - return self.create("memory", title, content, id=id, path=path, metadata=metadata, global_=global_, **kwargs) + return self.create("memory", title, content, id=id, path=path, metadata=metadata, scope=scope, **kwargs) def update_memory( self, @@ -548,13 +567,13 @@ def update_memory( *, path: str | None = None, metadata: dict[str, Any] | None = None, - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: - return self.update("memory", id, title, content, path=path, metadata=metadata, global_=global_, **kwargs) + return self.update("memory", id, title, content, path=path, metadata=metadata, scope=scope, **kwargs) - def delete_memory(self, id: str, *, global_: bool = False, **kwargs: Any) -> bool: - return self.delete("memory", id, global_=global_, **kwargs) + def delete_memory(self, id: str, *, scope: HarnessScope | None = None, **kwargs: Any) -> bool: + return self.delete("memory", id, scope=scope, **kwargs) def create_prompt_note( self, @@ -564,10 +583,10 @@ def create_prompt_note( id: str | None = None, path: str = "policy", metadata: dict[str, Any] | None = None, - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: - return self.create("prompt", title, content, id=id, path=path, metadata=metadata, global_=global_, **kwargs) + return self.create("prompt", title, content, id=id, path=path, metadata=metadata, scope=scope, **kwargs) def update_prompt_note( self, @@ -577,13 +596,13 @@ def update_prompt_note( *, path: str | None = None, metadata: dict[str, Any] | None = None, - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: - return self.update("prompt", id, title, content, path=path, metadata=metadata, global_=global_, **kwargs) + return self.update("prompt", id, title, content, path=path, metadata=metadata, scope=scope, **kwargs) - def delete_prompt_note(self, id: str, *, global_: bool = False, **kwargs: Any) -> bool: - return self.delete("prompt", id, global_=global_, **kwargs) + def delete_prompt_note(self, id: str, *, scope: HarnessScope | None = None, **kwargs: Any) -> bool: + return self.delete("prompt", id, scope=scope, **kwargs) def create_skill( self, @@ -595,7 +614,7 @@ def create_skill( reference: dict[str, Any] | None = None, arguments: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: return self.create( @@ -607,7 +626,7 @@ def create_skill( reference=_validate_python_skill_reference(reference), arguments=arguments, metadata=metadata, - global_=global_, + scope=scope, **kwargs, ) @@ -621,7 +640,7 @@ def update_skill( reference: dict[str, Any] | None = None, arguments: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: # Only validate a reference when one is supplied; omitting it preserves the @@ -637,12 +656,12 @@ def update_skill( reference=validated_reference, arguments=arguments, metadata=metadata, - global_=global_, + scope=scope, **kwargs, ) - def delete_skill(self, id: str, *, global_: bool = False, **kwargs: Any) -> bool: - return self.delete("skill", id, global_=global_, **kwargs) + def delete_skill(self, id: str, *, scope: HarnessScope | None = None, **kwargs: Any) -> bool: + return self.delete("skill", id, scope=scope, **kwargs) def create_subagent( self, @@ -652,10 +671,10 @@ def create_subagent( id: str | None = None, path: str = "general", metadata: dict[str, Any] | None = None, - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: - return self.create("subagent", title, content, id=id, path=path, metadata=metadata, global_=global_, **kwargs) + return self.create("subagent", title, content, id=id, path=path, metadata=metadata, scope=scope, **kwargs) def update_subagent( self, @@ -665,13 +684,13 @@ def update_subagent( *, path: str | None = None, metadata: dict[str, Any] | None = None, - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> HarnessEntry: - return self.update("subagent", id, title, content, path=path, metadata=metadata, global_=global_, **kwargs) + return self.update("subagent", id, title, content, path=path, metadata=metadata, scope=scope, **kwargs) - def delete_subagent(self, id: str, *, global_: bool = False, **kwargs: Any) -> bool: - return self.delete("subagent", id, global_=global_, **kwargs) + def delete_subagent(self, id: str, *, scope: HarnessScope | None = None, **kwargs: Any) -> bool: + return self.delete("subagent", id, scope=scope, **kwargs) def record_refinement( self, @@ -681,10 +700,10 @@ def record_refinement( evidence: str = "", outcome: str = "", id: str | None = None, - global_: bool = False, + scope: HarnessScope | None = None, **kwargs: Any, ) -> RefinementEvent: - if target := self._global_target(global_, kwargs): + if target := self._scope_target(scope, kwargs): return target.record_refinement(trigger, changes, evidence=evidence, outcome=outcome, id=id) self._ensure_local_writable() self._sync_from_disk() @@ -718,8 +737,8 @@ def plan_refinement( plan.append(f"Immediate validation step: {next_step}") return plan - def overview(self, *, max_entries_per_kind: int = 20, global_: bool = False, **kwargs: Any) -> str: - if target := self._global_target(global_, kwargs): + def overview(self, *, max_entries_per_kind: int = 20, scope: HarnessScope | None = None, **kwargs: Any) -> str: + if target := self._scope_target(scope, kwargs): return target.overview(max_entries_per_kind=max_entries_per_kind) self._sync_from_disk() lines = [ @@ -767,8 +786,8 @@ def overview(self, *, max_entries_per_kind: int = 20, global_: bool = False, **k lines.append("refinements: 0") return "\n".join(lines) - def snapshot(self, *, global_: bool = False, **kwargs: Any) -> dict[str, Any]: - if target := self._global_target(global_, kwargs): + def snapshot(self, *, scope: HarnessScope | None = None, **kwargs: Any) -> dict[str, Any]: + if target := self._scope_target(scope, kwargs): return target.snapshot() self._sync_from_disk() return { @@ -783,28 +802,27 @@ def snapshot(self, *, global_: bool = False, **kwargs: Any) -> dict[str, Any]: def get_harness_state( - state_dir: str | Path | None = None, *, global_: bool = False, **kwargs: Any + state_dir: str | Path | None = None, *, scope: HarnessScope | None = None, **kwargs: Any ) -> HarnessState: - """Return the cached local harness state, or global when requested.""" - global_ = _resolve_global_flag(global_, kwargs) - file_path = _state_file(state_dir, global_=global_) - scope: HarnessScope = "global" if global_ else "local" - cache_key = (file_path, scope) + """Return the cached harness state for a scope (local by default).""" + resolved_scope: HarnessScope = _normalize_scope(scope, kwargs) or "local" + file_path = _state_file(state_dir, scope=resolved_scope) + cache_key = (file_path, resolved_scope) state = _state_cache.get(cache_key) if state is None: - state = HarnessState(file_path, scope=scope) + state = HarnessState(file_path, scope=resolved_scope) # Recorded at construction only: an instance created from env defaults must - # keep targeting RLM_GLOBAL_HARNESS_STATE_DIR even when a later explicit + # keep targeting the env-resolved stores even when a later explicit # state_dir call aliases the same local file. An explicit dir that merely - # aliases the env resolution must not sandbox later global_=True writes + # aliases the env resolution must not sandbox later cross-scope writes # either, so pin only when the explicit dir actually diverges. if state_dir is not None: try: - env_file: Path | None = _state_file(global_=global_) + env_file: Path | None = _state_file(scope=resolved_scope) except RuntimeError: env_file = None if file_path != env_file: - state._global_target_state_dir = Path(state_dir).expanduser().resolve() + state._delegate_state_dir = Path(state_dir).expanduser().resolve() _state_cache[cache_key] = state return state diff --git a/prime-agent-runtime/test/test_harness.py b/prime-agent-runtime/test/test_harness.py index 7409f32a8..1de2299f3 100644 --- a/prime-agent-runtime/test/test_harness.py +++ b/prime-agent-runtime/test/test_harness.py @@ -21,6 +21,13 @@ class HarnessStateTest(unittest.TestCase): + @staticmethod + def _restore_env(name: str, previous: str | None) -> None: + if previous is None: + os.environ.pop(name, None) + else: + os.environ[name] = previous + def test_crud_for_all_entry_kinds(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: state = HarnessState(Path(temp_dir) / "harness_state.json") @@ -357,14 +364,14 @@ def test_in_memory_state_never_touches_disk(self) -> None: else: os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global - def test_in_memory_state_global_flag_uses_global_env_store(self) -> None: + def test_in_memory_state_global_scope_uses_global_env_store(self) -> None: previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") with tempfile.TemporaryDirectory() as temp_dir: global_dir = Path(temp_dir) / "global" os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(global_dir) try: state = HarnessState(in_memory=True) - global_entry = state.create_memory("Global note", "persisted", id="global_note", global_=True) + global_entry = state.create_memory("Global note", "persisted", id="global_note", scope="global") finally: if previous_global is None: os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) @@ -380,7 +387,7 @@ def test_in_memory_state_global_flag_uses_global_env_store(self) -> None: "persisted", ) - def test_in_memory_state_global_flag_uses_default_global_store(self) -> None: + def test_in_memory_state_global_scope_uses_default_global_store(self) -> None: previous_agent_dir = os.environ.get("PRIME_AGENT_CODING_AGENT_DIR") previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") with tempfile.TemporaryDirectory() as temp_dir: @@ -389,7 +396,7 @@ def test_in_memory_state_global_flag_uses_default_global_store(self) -> None: os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) try: state = HarnessState(in_memory=True) - global_entry = state.create_memory("Default global", "persisted", id="default_global", global_=True) + global_entry = state.create_memory("Default global", "persisted", id="default_global", scope="global") finally: if previous_agent_dir is None: os.environ.pop("PRIME_AGENT_CODING_AGENT_DIR", None) @@ -475,7 +482,7 @@ def test_explicit_state_dir_cache_uses_harness_state_file(self) -> None: self.assertIs(state, again) self.assertEqual(state.file_path, Path(temp_dir).resolve() / "harness_state.json") - def test_explicit_state_dir_global_flag_uses_matching_state_file(self) -> None: + def test_explicit_state_dir_global_scope_uses_matching_state_file(self) -> None: previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") with tempfile.TemporaryDirectory() as temp_dir: explicit_dir = Path(temp_dir) / "explicit" @@ -483,7 +490,7 @@ def test_explicit_state_dir_global_flag_uses_matching_state_file(self) -> None: os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(env_global_dir) try: state = get_harness_state(explicit_dir) - global_entry = state.create_memory("Scoped global", "custom dir", id="scoped_global", global_=True) + global_entry = state.create_memory("Scoped global", "custom dir", id="scoped_global", scope="global") finally: if previous_global is None: os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) @@ -513,7 +520,7 @@ def test_env_default_state_keeps_env_global_target_after_explicit_dir_cache_hit( "Env global", "still targets the env global dir", id="env_global_after_hit", - global_=True, + scope="global", ) finally: if previous_local is None: @@ -591,7 +598,58 @@ def test_global_scope_default_state_uses_global_harness_env_dir(self) -> None: self.assertEqual(state.scope, "global") self.assertEqual(state.file_path, global_dir.resolve() / "harness_state.json") - def test_default_state_is_local_and_global_flag_targets_global_store(self) -> None: + def test_project_scope_uses_project_env_dir(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_project = os.environ.get("RLM_PROJECT_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + local_dir = Path(temp_dir) / "local" + project_dir = Path(temp_dir) / "project" + os.environ["RLM_HARNESS_STATE_DIR"] = str(local_dir) + os.environ["RLM_PROJECT_HARNESS_STATE_DIR"] = str(project_dir) + try: + state = get_harness_state() + project_state = get_harness_state(scope="project") + project_entry = state.create_memory( + "Project note", "This repository only.", id="project_note", scope="project" + ) + prefixed_entry = state.create_memory( + "Prefixed project note", "Also this repository.", id="project:prefixed_project_note" + ) + finally: + self._restore_env("RLM_HARNESS_STATE_DIR", previous_local) + self._restore_env("RLM_PROJECT_HARNESS_STATE_DIR", previous_project) + + self.assertEqual(project_state.file_path, project_dir.resolve() / "harness_state.json") + self.assertEqual(project_entry.scope, "project") + self.assertEqual(prefixed_entry.id, "prefixed_project_note") + self.assertEqual(prefixed_entry.scope, "project") + self.assertIsNone(HarnessState(local_dir / "harness_state.json").get("memory", "project_note")) + project_store = HarnessState(project_dir / "harness_state.json", scope="project") + self.assertIsNotNone(project_store.get("memory", "project_note")) + self.assertIsNotNone(project_store.get("memory", "prefixed_project_note")) + + def test_project_scope_without_env_uses_repository_config_dir(self) -> None: + previous_project = os.environ.get("RLM_PROJECT_HARNESS_STATE_DIR") + previous_cwd = Path.cwd() + with tempfile.TemporaryDirectory() as temp_dir: + repo_root = Path(temp_dir).resolve() / "repo" + nested = repo_root / "packages" / "app" + nested.mkdir(parents=True) + (repo_root / ".git").mkdir() + os.environ.pop("RLM_PROJECT_HARNESS_STATE_DIR", None) + try: + os.chdir(nested) + state = HarnessState(scope="project") + finally: + os.chdir(previous_cwd) + self._restore_env("RLM_PROJECT_HARNESS_STATE_DIR", previous_project) + + self.assertEqual( + state.file_path, + repo_root / ".prime" / "agent" / "harness" / "harness_state.json", + ) + + def test_default_state_is_local_and_global_scope_targets_global_store(self) -> None: previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") with tempfile.TemporaryDirectory() as temp_dir: @@ -601,14 +659,13 @@ def test_default_state_is_local_and_global_flag_targets_global_store(self) -> No os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(global_dir) try: state = get_harness_state() - global_state = get_harness_state(global_=True) + global_state = get_harness_state(scope="global") local_entry = state.create_memory("Local note", "Only this session.", id="local_note") - global_entry = state.create_memory("Global note", "All sessions.", id="global_note", global_=True) - kwargs_entry = state.create_memory( - "Kwargs global note", - "All sessions via kwargs.", - id="kwargs_global_note", - **{"global": True}, + global_entry = state.create_memory("Global note", "All sessions.", id="global_note", scope="global") + prefixed_entry = state.create_memory( + "Prefixed global note", + "All sessions via a prefixed id.", + id="global:prefixed_global_note", ) finally: if previous_local is None: @@ -624,20 +681,22 @@ def test_default_state_is_local_and_global_flag_targets_global_store(self) -> No self.assertEqual(global_state.file_path, global_dir.resolve() / "harness_state.json") self.assertEqual(local_entry.scope, "local") self.assertEqual(global_entry.scope, "global") - self.assertEqual(kwargs_entry.scope, "global") + self.assertEqual(prefixed_entry.scope, "global") self.assertIsNotNone(HarnessState(local_dir / "harness_state.json").get("memory", "local_note")) self.assertIsNone(HarnessState(local_dir / "harness_state.json").get("memory", "global_note")) self.assertIsNotNone(HarnessState(global_dir / "harness_state.json", scope="global").get("memory", "global_note")) self.assertIsNotNone( - HarnessState(global_dir / "harness_state.json", scope="global").get("memory", "kwargs_global_note") + HarnessState(global_dir / "harness_state.json", scope="global").get("memory", "prefixed_global_note") ) - def test_global_kwarg_must_be_boolean(self) -> None: + def test_unknown_scope_and_legacy_global_kwarg_are_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: state = HarnessState(Path(temp_dir) / "harness_state.json") - with self.assertRaisesRegex(TypeError, "global must be a bool"): - state.create_memory("Bad global flag", "bad", id="bad_global", **{"global": "false"}) + with self.assertRaisesRegex(ValueError, "scope must be one of"): + state.create_memory("Bad scope", "bad", id="bad_scope", scope="workspace") + with self.assertRaisesRegex(TypeError, "unexpected keyword argument 'global'"): + state.create_memory("Legacy flag", "bad", id="legacy_global", **{"global": True}) def test_state_cache_keeps_scope_distinct_when_local_and_global_share_a_file(self) -> None: previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") @@ -647,9 +706,9 @@ def test_state_cache_keeps_scope_distinct_when_local_and_global_share_a_file(sel os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = temp_dir try: state = get_harness_state() - global_state = get_harness_state(global_=True) + global_state = get_harness_state(scope="global") local_entry = state.create_memory("Local note", "Only this session.", id="local_note") - global_entry = state.create_memory("Global note", "All sessions.", id="global_note", global_=True) + global_entry = state.create_memory("Global note", "All sessions.", id="global_note", scope="global") finally: if previous_local is None: os.environ.pop("RLM_HARNESS_STATE_DIR", None) @@ -680,10 +739,10 @@ def test_scope_prefixed_ids_route_to_the_displayed_scope(self) -> None: os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(global_dir) try: state = get_harness_state() - state.create_memory("Global note", "v1", id="routed", global_=True) + state.create_memory("Global note", "v1", id="routed", scope="global") # The overview displays [global:routed]; that id must be usable as-is - # and imply the global scope without passing global_. + # and imply the global scope without passing scope. updated = state.update_memory("global:routed", "Global note", "v2") self.assertEqual(updated.scope, "global") self.assertEqual(state.get("memory", "global:routed").content, "v2") @@ -733,7 +792,9 @@ def test_create_with_prefixed_id_does_not_mint_literal_id(self) -> None: self.assertEqual(entry.scope, "global") global_store = HarnessState(global_dir / "harness_state.json", scope="global") self.assertIsNotNone(global_store.get("memory", "validation")) - self.assertIsNone(global_store.get("memory", "global:validation")) + # The prefix is display-only, so it resolves to the bare id in the same store. + self.assertEqual(global_store.get("memory", "global:validation").id, "validation") + self.assertNotIn("global:validation", global_store.entries["memory"]) self.assertFalse((local_dir / "harness_state.json").exists()) def test_module_harness_binds_lazily_to_env_set_after_import(self) -> None: @@ -747,7 +808,7 @@ def test_module_harness_binds_lazily_to_env_set_after_import(self) -> None: os.environ.pop("RLM_HARNESS_STATE_DIR", None) os.environ.pop("RLM_SESSION_DIR", None) # Without local env, local writes fail loudly instead of vanishing. - with self.assertRaisesRegex(RuntimeError, "global_=True"): + with self.assertRaisesRegex(RuntimeError, 'scope="global"'): package_harness.create_memory("Volatile", "pre-env", id="pre_env") os.environ["RLM_HARNESS_STATE_DIR"] = temp_dir @@ -781,7 +842,7 @@ def test_module_harness_without_env_raises_on_local_writes_and_reads_work(self) lambda: package_harness.upsert("memory", "Lost", "content", id="lost"), lambda: package_harness.record_refinement("trigger", ["change"]), ): - with self.assertRaisesRegex(RuntimeError, "Local harness state requires.*global_=True"): + with self.assertRaisesRegex(RuntimeError, 'Local harness state requires.*scope="global"'): mutate() # Reads keep working against an empty view. @@ -809,7 +870,7 @@ def test_module_harness_without_env_still_routes_global_writes(self) -> None: os.environ.pop("RLM_HARNESS_STATE_DIR", None) os.environ.pop("RLM_SESSION_DIR", None) os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(global_dir) - entry = package_harness.create_memory("Lesson", "keep me", id="no_session_lesson", global_=True) + entry = package_harness.create_memory("Lesson", "keep me", id="no_session_lesson", scope="global") finally: if previous_local is None: os.environ.pop("RLM_HARNESS_STATE_DIR", None) @@ -885,7 +946,7 @@ def test_explicit_dir_aliasing_env_local_dir_keeps_env_global_target(self) -> No # First construction happens via an explicit dir that merely aliases # the env local dir; global writes must still hit the env global dir. state = get_harness_state(local_dir) - global_entry = state.create_memory("Aliased", "still global", id="alias_global", global_=True) + global_entry = state.create_memory("Aliased", "still global", id="alias_global", scope="global") finally: if previous_local is None: os.environ.pop("RLM_HARNESS_STATE_DIR", None) From 5b69daf64c7938aad3fa93dabe12dab9ee038d27 Mon Sep 17 00:00:00 2001 From: jungminjo Date: Mon, 10 Aug 2026 14:36:28 +0900 Subject: [PATCH 2/2] test(coding-agent): make harness persistence test portable --- packages/coding-agent/test/refinement.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/test/refinement.test.ts b/packages/coding-agent/test/refinement.test.ts index 3dd81b09d..f61089cf6 100644 --- a/packages/coding-agent/test/refinement.test.ts +++ b/packages/coding-agent/test/refinement.test.ts @@ -1,6 +1,6 @@ import { appendFileSync, chmodSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type * as PiAi from "@earendil-works/pi-ai"; import type { AssistantMessage, Model } from "@earendil-works/pi-ai"; @@ -217,10 +217,12 @@ describe("harness refinement", () => { const statePath = saveHarnessState(harnessStateDir, state); expect(loadHarnessState(harnessStateDir).entries.memory.memory_entry).toBeDefined(); - expect(readdirSync(harnessStateDir)).toEqual([statePath.split("/").at(-1)]); - chmodSync(statePath, 0o600); - saveHarnessState(harnessStateDir, state); - expect(statSync(statePath).mode & 0o777).toBe(0o600); + expect(readdirSync(harnessStateDir)).toEqual([basename(statePath)]); + if (process.platform !== "win32") { + chmodSync(statePath, 0o600); + saveHarnessState(harnessStateDir, state); + expect(statSync(statePath).mode & 0o777).toBe(0o600); + } }); it("applies create, update, and delete for every editable harness kind", () => {