Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Vendored from [`code-yeongyu/pi-rules`](https://github.com/code-yeongyu/pi-rules
- `ui/dynamic-border.ts` and `ui/rules-banner.ts`: constructor parameter properties (`private readonly …`) -> explicit fields + constructor assignment (senpi's root tsconfig is `erasableSyntaxOnly`; parameter properties are disallowed).
- Runtime dep `picomatch` (+ `@types/picomatch`) added to `package.json`.
- `rules/project-root.ts`: `findProjectRoot` stops the marker walk when `dirname()` stops progressing, fixing an infinite synchronous loop for targets on a different Windows drive than cwd (or UNC shares). Behavior fix also submitted upstream: https://github.com/code-yeongyu/pi-rules/pull/19 — drop this adaptation once a fixed pi-rules release is re-vendored.
- `rules/finder.ts`: `isSameOrChildPath` rejects an absolute `relative()` result via `isAbsolute()` instead of `startsWith("/")`. On Windows, `relative()` between two different drive roots returns an absolute path (`relative("C:\\proj", "D:\\other")` -> `"D:\\other"`) that starts with neither `".."` nor `"/"`, so the containment test accepted it and `getWalkDirectories` walked the other drive — collecting `AGENTS.md` / `CLAUDE.md` / `.claude/rules` from an unrelated drive as *project* rules. Matches the sibling helper in `rules/engine.ts`, which already uses `isAbsolute()`. POSIX behavior is unchanged (`isAbsolute` and `startsWith("/")` agree there). Propose upstream in `code-yeongyu/pi-rules` and drop the adaptation once a release carrying it is re-vendored.
- `rules/constants.ts` + `rules/formatter.ts`: `formatStaticBlock` wraps its output in a model-facing `<project_rules>` … `</project_rules>` envelope, and wraps that in opaque region sentinels (`PROJECT_RULES_REGION_START_MARKER` / `..._END_MARKER`). Provider lanes that rebuild the system prompt instead of forwarding senpi's composed one — the `claude-agent-sdk` builtin — need an explicitly bounded region to extract; unbounded, the block is either dropped entirely or read to end-of-string, which swallows the sections extensions registered later (`mcp`) append below it. The sentinels exist because the semantic `<project_rules>` tags cannot identify the block: surrounding prompt content this builtin does not own (context files before it, extensions appending after it) may legitimately contain them and would be extracted instead. Rule headings and bodies keep their text, except that the four marker literals are neutralized to their `&lt;…&gt;` form: a rule quoting a raw sentinel would terminate extraction early and silently drop every rule after it, while a rule quoting a raw semantic tag would corrupt the envelope structure the model reads. An extension cannot express any of this because the block is produced inside this builtin. Propose upstream in `code-yeongyu/pi-rules` and drop the adaptation once a release carrying it is re-vendored.
- `index.ts`: `before_agent_start` no longer gates static rule selection on `engine.isStaticInjected(rule)`. The host re-emits that event from the BASE system prompt on every user prompt (`core/agent-session.ts`), so a mark written on turn 1 removed the block from turn 2 onward — on every provider, not just the SDK lane. The marks are still written; they now serve only the dynamic `tool_result` path's dedup. Same upstream-proposal note as above.
- No other behavior changes. Registers `/rules` and `/reload-rules` and discovers rule files from `.sisyphus/rules`, `.claude/rules`, `.cursor/rules`, `.github/instructions`, `AGENTS.md`, `CLAUDE.md`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, posix, relative, resolve } from "node:path";
import { dirname, isAbsolute, join, posix, relative, resolve } from "node:path";

import {
GLOBAL_DISTANCE,
Expand Down Expand Up @@ -246,7 +246,10 @@ function getWalkDirectories(projectRoot: string, targetFile: string | null): Wal

function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const childRelativePath = relative(parentPath, childPath);
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/"));
// Cross-drive / UNC: relative() between two Windows roots returns an absolute
// path (e.g. "D:\other"), which starts with neither ".." nor "/", so a
// startsWith("/") test would accept a target outside the project root.
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
}

function readSingleFileInfo(filePath: string): SingleFileInfo | null {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { win32 } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { findRuleCandidates } from "../../../src/core/extensions/builtin/rules/rules/finder.ts";

const { projectRoot, crossDriveTarget, crossDriveAgentsMd, homeDir } = vi.hoisted(() => ({
projectRoot: "C:\\workspace\\proj",
crossDriveTarget: "D:\\other\\file.ts",
crossDriveAgentsMd: "D:\\other\\AGENTS.md",
homeDir: "C:\\Users\\test",
}));

vi.mock("node:fs", () => ({
existsSync: (path: string) => path === crossDriveAgentsMd,
statSync: () => ({ isFile: () => true, isDirectory: () => false }),
lstatSync: () => ({ isSymbolicLink: () => false }),
readdirSync: () => [],
realpathSync: Object.assign((path: string) => path, { native: (path: string) => path }),
}));

vi.mock("node:path", async (importOriginal) => {
const path = await importOriginal<typeof import("node:path")>();
return {
...path,
dirname: path.win32.dirname,
join: path.win32.join,
relative: path.win32.relative,
resolve: path.win32.resolve,
Comment on lines +24 to +27

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 Badge Mock the Windows isAbsolute implementation

On Linux and macOS runners, this mock replaces relative with its Win32 variant but leaves isAbsolute as the host POSIX implementation. The finder therefore evaluates isAbsolute("D:\\other") as false, still treats the cross-drive target as a child, and returns the mocked D:\\other\\AGENTS.md, causing the new assertion to fail. Override isAbsolute with path.win32.isAbsolute so this regression runs successfully on every supported CI platform.

AGENTS.md reference: packages/coding-agent/test/AGENTS.md:L43-L46

Useful? React with 👍 / 👎.

};
});

describe("rules finder cross-drive project scope", () => {
it("#given a target file on a different drive than the project root #when collecting project rule candidates #then rules outside the project root are not collected", () => {
// given
expect(win32.relative(projectRoot, "D:\\other")).toBe("D:\\other");

// when
const candidates = findRuleCandidates({
projectRoot,
targetFile: crossDriveTarget,
homeDir,
skipUserHome: true,
});

// then
expect(candidates.map((candidate) => candidate.path)).toEqual([]);
});
});