Skip to content
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,19 @@ list` only sees this clone. `attachSiblingClones` in `src/repo.js` also searches
are stat'd (`isDirectoryEntry`), fail-soft, and a broken or cyclic link reads as absent. One
library reached through k links is k loaded entries, billed k times - `loadedCopies` multiplies
a description-line delta by that count in both `buildProposal` and the writer's projection.
`skillSearchPaths` (config) is read-only awareness for skills that live outside the repo
(a shared/fleet library); `loadConfig` expands `~` and folds it into the existing
`skillsDirs` awareness list (consulted after `skillsDir`), so every read site already sees
it. Writes never target a search path: `resolveOverflowTarget` only returns `skillsDir`,
and a skill resolving outside the repo is withheld from the synthesis staging copy in
every scope, including user scope where `allowExternal` would otherwise let staging reach
any external root (`confinementReason`/`canonicalizeSearchPathRoots` in `src/workspace.js`).
A search-path skill still rides the synthesis fingerprint unlike an ordinary withheld
skill, so a direct write to one fails the run instead of escaping detection
(`assertRepoUntouched` in `src/synthesize.js`). A configured root that equals or contains
the repo root or `skillsDir` is rejected at config validation and dropped by
`canonicalizeSearchPathRoots`, so the repo's own files can never be marked read-only by
their own search path.
- **Memory resolution is pointer-aware** (`resolveMemoryFiles` in `src/memory.js`): the
first configured file is canonical, a `@AGENTS.md`-only CLAUDE.md is a pointer, and a
second full file is warned about, never silently ignored or double-written.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ CLI flags on top:
"memoryFiles": ["AGENTS.md"],
"budgetTokens": 5000,
"skillsDir": ".agents/skills",
"skillSearchPaths": [],
"maxEditsPerRun": null,
"minGapEvidence": 2,
"gapLedgerMaxAge": "90d",
Expand Down Expand Up @@ -728,6 +729,17 @@ regular settings; its path and user-only settings include `memoryFiles`, `skills
instead, such as `.claude/skills`, configure that path; a missing configured directory
falls back to the default. Backpass normalizes path separators and trailing slashes.

`skillSearchPaths` lists additional directories to consult for _existing_ skills,
alongside `skillsDir` and consulted after it in list order. `~` is expanded. Use it when
your canonical skill library lives outside the repo - a machine-wide shared tree, or
`~/.claude/skills` - so backpass recognizes those skills as already existing: an
`AGENTS.md` reference into the shared tree is not treated as dangling, a failed trigger
against a shared skill is reported as already covered instead of proposed as a duplicate
(backpass cannot tune its description - the file stays read-only, in every scope), and an
extraction whose content substantially matches a shared skill becomes a pointer edit
rather than new content. This is read-only awareness: backpass never writes into a
search path - every write still targets only `skillsDir`.

```json
{
"user": {
Expand Down
131 changes: 129 additions & 2 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export const DEFAULT_CONFIG = {
memoryFiles: ["AGENTS.md", "CLAUDE.md"],
budgetTokens: 5000,
skillsDir: ".agents/skills",
/**
* Extra directories to consult for *existing* skills, alongside `skillsDir`, when
* deciding whether an AGENTS.md pointer already resolves, whether a failed trigger
* needs a description edit instead of duplicate content, and whether an extraction
* should point at a shared skill rather than create a new one. `~` is expanded. These
* are read-only awareness: writes always target only `skillsDir`, never a search path
* (a skill resolving outside the repo is withheld from the synthesis staging copy).
*/
skillSearchPaths: [],
/** `null` means adaptive: see `effectiveMaxEdits` in proposal.js. An integer pins it. */
maxEditsPerRun: null,
minGapEvidence: 2,
Expand Down Expand Up @@ -142,6 +151,87 @@ export const USER_CONFIG_DEFAULTS = {
},
};

/** True for a filesystem root spelling: `/`, repeated separators, or a Windows volume root. */
function isFilesystemRoot(p) {
const s = p.trim();
if (/^\/+$/.test(s) || /^\\+$/.test(s)) return true;
if (/^[A-Za-z]:[/\\]*$/.test(s)) return true;
return false;
}

/** True when `candidate` is `root`, or lies inside it. Both must already be resolved. */
export function isAncestorOrEqual(root, candidate) {
return root === candidate || candidate.startsWith(`${root}${path.sep}`);
}

/** Expand a leading `~` to the home directory; other paths pass through unchanged. */
export function expandHomePath(p, home = os.homedir()) {
if (typeof p !== "string") return p;
if (p === "~") return home;
if (p.startsWith("~/")) return path.join(home, p.slice(2));
return p;
}

/**
* Canonical identities of the configured `skillSearchPaths` roots, resolved EXACTLY the
* way a skill source is (`prepareWorkspace` builds a source with `path.join(repo.root, ...)`
* then compares `fs.realpathSync` identities): expand `~`, resolve a relative entry
* against the repository root - never the process working directory - then follow links.
* Building it any other way is how the read-only promise fails open: `fs.realpathSync` on a
* raw relative value resolves against `process.cwd()`, so it never matches the real source
* and the refusal never fires.
*
* Fail CLOSED: a configured root that exists but cannot be canonicalised raises a clear
* error naming `config.skillSearchPaths` rather than being dropped - silently discarding it
* would delete the promise instead of enforcing it. A not-yet-existing root (`ENOENT`)
* keeps its resolved absolute path as its identity: nothing can load from, or be written
* to, a directory that does not exist, so the promise stays whole either way.
*
* A root that is the repository itself, an ancestor of it (e.g. "~" with the repo checked
* out under $HOME), or that equals or contains the repo's own configured `skillsDir` (e.g.
* ".agents" covering ".agents/skills") is dropped rather than registered: `validate()`
* below rejects that shape at load time, but this function is also reachable directly
* (tests, future callers), and such a root would otherwise mark files backpass exists to
* write as "inside a search path" - the repo's own containment must win for its own files.
*/
export function canonicalizeSearchPathRoots(repoRoot, roots = [], skillsDir = null, home = os.homedir()) {
// Reference points (the repo root and its skillsDir) resolve leniently: any resolution
// failure falls back to the plain absolute path rather than aborting, since a not-yet-
// created skillsDir must still win its own containment check.
const resolveReference = (candidate) => {
const absolute = path.isAbsolute(candidate) ? candidate : path.resolve(repoRoot, candidate);
try {
return fs.realpathSync(absolute);
} catch {
return absolute;
}
};
const identities = new Set();
const repoIdentity = resolveReference(repoRoot);
const skillsIdentity = skillsDir ? resolveReference(skillsDir) : null;
for (const raw of roots) {
const expanded = expandHomePath(raw, home);
const absolute = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded);
let identity;
try {
identity = fs.realpathSync(absolute);
} catch (err) {
if (err && err.code === "ENOENT") {
identity = absolute;
} else {
throw new UserError(
`config.skillSearchPaths root "${raw}" cannot be resolved (${err.message})`,
"point it at a readable directory, or remove it from skillSearchPaths",
);
}
}
if (isAncestorOrEqual(identity, repoIdentity)) continue;
if (skillsIdentity && isAncestorOrEqual(identity, skillsIdentity)) continue;
identities.add(identity);
}
return identities;
}

export function parseScopeKind(value) {
if (value === undefined || value === null || value === "") return "project";
if (value === "project" || value === "user") return value;
Expand Down Expand Up @@ -222,7 +312,7 @@ export function sinceCutoff(since, now = Date.now()) {
return window === null ? null : now - window;
}

function validate(config, { kind = "project" } = {}) {
function validate(config, { kind = "project", repoRoot = null } = {}) {
if (!Array.isArray(config.memoryFiles) || config.memoryFiles.length === 0) {
throw new UserError("config.memoryFiles must be a non-empty array");
}
Expand All @@ -247,6 +337,37 @@ function validate(config, { kind = "project" } = {}) {
throw new UserError("config.skillsDirs must be an array of paths");
}
}
if (config.skillSearchPaths !== undefined) {
if (!Array.isArray(config.skillSearchPaths) || config.skillSearchPaths.some((d) => typeof d !== "string")) {
throw new UserError("config.skillSearchPaths must be an array of paths");
}
for (const entry of config.skillSearchPaths) {
if (!entry.trim()) {
throw new UserError("config.skillSearchPaths entries must be non-empty path strings");
}
// The filesystem root as a search path would mark every path read-only and leave
// nothing stageable - a degenerate value that must be rejected, not enforced.
if (isFilesystemRoot(entry)) {
throw new UserError(
`config.skillSearchPaths entry "${entry}" must not be the filesystem root`,
"name a specific shared skills directory",
);
}
// A root that is the repo itself, an ancestor of it (e.g. "~" with the repo checked
// out under $HOME), or that equals or contains the repo's own configured skillsDir
// (e.g. ".agents" covering ".agents/skills") would mark files backpass exists to
// write as "inside a search path" too - the same degenerate shape as the
// filesystem-root case above. `canonicalizeSearchPathRoots` is the one place that
// decides this, both here (reject at load) and at runtime (drop for direct callers) -
// never duplicate the comparison.
if (repoRoot && canonicalizeSearchPathRoots(repoRoot, [entry], config.skillsDir).size === 0) {
throw new UserError(
`config.skillSearchPaths entry "${entry}" must not be the repository root or its skillsDir, or an ancestor of either`,
"name a shared skills directory outside the repository",
);
}
}
}
const includeProjects = config.discovery.includeProjects;
const excludeProjects = config.discovery.excludeProjects;
if (
Expand Down Expand Up @@ -378,7 +499,13 @@ export function loadConfig(repoRoot, overrides = {}, { kind = "project" } = {})
if (config.discovery.includeCursorIde && !config.discovery.harnesses.includes("cursor-ide")) {
config.discovery.harnesses = [...config.discovery.harnesses, "cursor-ide"];
}
return validate(config, { kind: scopeKind });
const validated = validate(config, { kind: scopeKind, repoRoot });
// `skillSearchPaths` is the read-side awareness key. It rides the existing `skillsDirs`
// awareness list (consulted after `skillsDir` in list order) rather than a second plumbing;
// `~` is expanded here so the loaders that join `repoRoot` never mishandle a home path.
const searchPaths = (validated.skillSearchPaths || []).map((p) => expandHomePath(p));
if (searchPaths.length) validated.skillsDirs = [...(validated.skillsDirs || []), ...searchPaths];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Search paths become writable

Appending skillSearchPaths to skillsDirs removes the distinction between read-only search paths and writable skill roots. In user scope, synthesis allows external roots, so a writable shared-library skill can be staged, measured, and later updated by the apply writer. A backpass --scope user run can therefore modify a search path even though the accepted intent requires these paths to remain read-only and all writes to target only skillsDir. Preserve the read-only provenance of search paths through staging and application.

Context Used: If there is a VISION.md file at the root of the repo, the PR must not conflict / diverge / drift from it. If the PR description has an "Intent" section, respect that as the accepted user intent. - Do make comments if anything in the implementation ... (source)

Knowledge Base Used: Workspace state and configuration

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 External writes escape detection

In project scope, shared skills are withheld from staging, but their absolute source paths are still shown to the synthesis agent for grounding and excluded from the repository fingerprint. If the synthesis harness edits one of those paths directly, the shared library changes without assertRepoUntouched detecting it. This violates the accepted read-only intent and the repository directive that model behavior must be enforced in code rather than trusted to prompt compliance. Include these exposed files in change detection or provide their contents without exposing writable source paths.

Context Used: If there is a VISION.md file at the root of the repo, the PR must not conflict / diverge / drift from it. If the PR description has an "Intent" section, respect that as the accepted user intent. - Do make comments if anything in the implementation ... (source)

Knowledge Base Used:

return validated;
}

/**
Expand Down
12 changes: 5 additions & 7 deletions src/scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { parseScopeKind, userStateDir } from "./config.js";
import { expandHomePath, parseScopeKind, userStateDir } from "./config.js";
import { associate as associateProject, associateRemote, globToRegExp } from "./discovery/association.js";
import { UserError, info } from "./logger.js";
import { gitProjectIdentity, gitToplevel, listWorktrees, normalizeRemote } from "./repo.js";
Expand All @@ -16,12 +16,7 @@ import { gitProjectIdentity, gitToplevel, listWorktrees, normalizeRemote } from
* `~/.config/backpass/user/` (0700). A run is exactly one scope, chosen by `--scope`.
*/

export function expandUserPath(p, home = os.homedir()) {
if (typeof p !== "string") return p;
if (p === "~") return home;
if (p.startsWith("~/")) return path.join(home, p.slice(2));
return p;
}
export const expandUserPath = expandHomePath;

/**
* Path relative to `root` when it sits under it, otherwise the absolute path.
Expand Down Expand Up @@ -199,6 +194,7 @@ function resolveProjectScope(repo, config) {
modelCwd: repo.root,
memoryFiles: config.memoryFiles,
skillDirs: config.skillsDirs || [],
skillSearchPaths: (config.skillSearchPaths || []).map((p) => expandUserPath(p)),
overflowDir: config.skillsDir,
associate: (descriptor, options = {}) => {
const result = associateProject(descriptor, repo, {
Expand Down Expand Up @@ -231,6 +227,7 @@ function resolveUserScope(cwd, config, { strict = false, home = os.homedir(), as
const memoryFiles = (config.memoryFiles || []).map((file) => pathInRoot(file, root, home));
const overflowDir = pathInRoot(config.skillsDir || ".agents/skills", root, home);
const skillDirs = (config.skillsDirs || []).map((dir) => pathInRoot(dir, root, home));
const skillSearchPaths = (config.skillSearchPaths || []).map((p) => expandUserPath(p, home));
const repo = syntheticUserRepo(root);
const stateDir = userStateDir();
const associationCache = new Map();
Expand Down Expand Up @@ -275,6 +272,7 @@ function resolveUserScope(cwd, config, { strict = false, home = os.homedir(), as
modelCwd: stateDir,
memoryFiles,
skillDirs,
skillSearchPaths,
overflowDir,
associate,
associateRemote: (descriptor, { facts, host }) => associateUserRemote(descriptor, { facts, host, strict }),
Expand Down
34 changes: 30 additions & 4 deletions src/synthesize.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";

import { extractJson, isBlankOutput, openSession, usageRecord } from "./acpx.js";
import { userClaudeSkillsDir } from "./config.js";
import { expandHomePath, userClaudeSkillsDir } from "./config.js";
import { renderEvidenceForPrompt } from "./fold.js";
import { renderInstructionIndex, resolveMemoryPath } from "./memory.js";
import { renderPrompt, render, loadPrompt } from "./prompts.js";
Expand All @@ -17,7 +17,13 @@ import {
import { isSuppressedByRejection } from "./state.js";
import { SURFACE_TARGET } from "./target.js";
import { emitProgress } from "./progress.js";
import { measureWorkspace, prepareWorkspace, repoFingerprint, workspacePathFor } from "./workspace.js";
import {
READ_ONLY_SEARCH_PATH,
measureWorkspace,
prepareWorkspace,
repoFingerprint,
workspacePathFor,
} from "./workspace.js";
import { UserError, color, info, warn } from "./logger.js";

/**
Expand Down Expand Up @@ -149,7 +155,9 @@ function harnessCountsOf(transcripts) {
* reaches that decision. An ordinary repository skill stays fingerprinted either way. A
* fingerprinted path can still resolve outside the repository - that is the ordinary
* user-scope layout - so a change there is reported for what it is rather than as a direct
* repository edit.
* repository edit. A configured `skillSearchPaths` root is the one withheld reason that
* stays fingerprinted anyway: its read-only promise is not "backpass will never write
* this," it is "nothing may ever write this," so a change there must still fail the run.
*/
function assertRepoUntouched(repo, before, workspaceRoot) {
const after = repoFingerprint(repo, Object.keys(before));
Expand Down Expand Up @@ -224,6 +232,11 @@ function synthesisSetup({ memoryFile, summary, config, repo, harnessCounts, scop
for (const w of overflow.warnings) warn(w);
const skillDirs = resolveProjectSkillDirs(repo.root, overflow.dir, config.skillsDirs || [], { exact: userScope });
const skillFiles = loadProjectSkills(repo.root, overflow.dir, config.skillsDirs || [], { exact: userScope });
// `skillSearchPaths` rides `skillDirs` for awareness (config.js), but staging must
// refuse it unconditionally - unlike the rest of `skillDirs`, it is never writable, in
// no scope, so `prepareWorkspace` needs the raw roots to enforce that independently of
// `allowExternal`.
const searchPathRoots = (config.skillSearchPaths || []).map((p) => expandHomePath(p));
// The budget is the whole always-loaded surface whatever the target: a skill target
// moves it by that skill's description-line delta, nothing else changes.
const descriptionTokens = skillDescriptionTokens(skillFiles);
Expand Down Expand Up @@ -259,6 +272,7 @@ function synthesisSetup({ memoryFile, summary, config, repo, harnessCounts, scop
overflow,
skillDirs,
skillFiles,
searchPathRoots,
target,
descriptionTokens,
maxEdits,
Expand Down Expand Up @@ -497,6 +511,7 @@ export async function synthesizeProposal({
overflow,
skillDirs,
skillFiles,
searchPathRoots,
target,
descriptionTokens,
maxEdits,
Expand All @@ -523,6 +538,7 @@ export async function synthesizeProposal({
skillDirs,
stagedSkills,
allowExternal: scope?.kind === "user",
searchPathRoots,
};
let workspace = prepareWorkspace(workspaceOptions);
const stagedSkillsDir =
Expand Down Expand Up @@ -569,9 +585,19 @@ export async function synthesizeProposal({
const editPromptFile = path.join(promptDir, "synthesis-edit.md");
fs.writeFileSync(editPromptFile, renderPrompt("synthesis", editValues));

// A search-path skill is unstageable like any other read-only skill, but unlike the
// rest of them the read-only promise it carries must be enforceable: a direct write to
// it has to be detected, not silently excused the way an ordinary withheld skill is
// (design note above `assertRepoUntouched`). It stays in the fingerprint so a change
// there still fails the run loudly.
const fingerprint = repoFingerprint(repo, [
memoryFile.path,
...skillFiles.filter((skill) => !readOnlyReason(skill.path)).map((skill) => skill.path),
...skillFiles
.filter((skill) => {
const reason = readOnlyReason(skill.path);
return !reason || reason === READ_ONLY_SEARCH_PATH;
})
.map((skill) => skill.path),
]);
const sessionName = `backpass-synth-${process.pid}`;
const timeoutSeconds = Math.max(config.timeoutSeconds, 900);
Expand Down
6 changes: 5 additions & 1 deletion src/target.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ export function resolveTarget(spec, scope) {
const skill = skillMatches[0];
// A targeted run writes exactly one file, and staging is what decides whether that
// file can be in the copy at all. Ask it here so the refusal names its own cause.
const refusal = skillStagingRefusal(root, skill.path, { allowExternal: user });
const refusal = skillStagingRefusal(root, skill.path, {
allowExternal: user,
searchPathRoots: scope.skillSearchPaths || [],
skillsDir: scope.overflowDir,
});
if (refusal) {
throw new UserError(
`--target ${spec} is at ${skill.path}, which ${refusal}`,
Expand Down
Loading
Loading