pi-code 1.0.72 → 1.0.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,7 +27,7 @@ import { publishInstructionLoad } from './internal/instruction-events.js'
27
27
  import { readManagedSettings } from './internal/managed-settings.js'
28
28
  import { type CompiledGlob, compileGlobs, matchesCompiledGlobs } from './internal/path-rules.js'
29
29
  import { isProjectApproved } from './internal/project-approval.js'
30
- import { findNearestDir } from './internal/project-root.js'
30
+ import { findNearestDir, sameLocation } from './internal/project-root.js'
31
31
  import { stripBlockComments } from './internal/strip-comments.js'
32
32
  import { fileToolTarget } from './internal/tool-target.js'
33
33
 
@@ -303,7 +303,10 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
303
303
  globalRules = readRules(globalRulesDir, isExcluded)
304
304
  // Nearest at-or-above cwd, so a subdirectory session still reads the rules the
305
305
  // approval walk gated on.
306
- const projectRulesDir = approved ? findNearestDir(ctx.cwd, path.join('.claude', 'rules')) : null
306
+ // Not the user's own rules dir: from $HOME (or under a dotfiles repo rooted there)
307
+ // the nearest one is ~/.claude/rules, which the global load above already read.
308
+ const nearestRulesDir = approved ? findNearestDir(ctx.cwd, path.join('.claude', 'rules')) : null
309
+ const projectRulesDir = nearestRulesDir !== null && sameLocation(nearestRulesDir, globalRulesDir) ? null : nearestRulesDir
307
310
  projectRules = projectRulesDir ? readRules(projectRulesDir, isExcluded) : EMPTY_RULES
308
311
 
309
312
  // Global globs are relative to cwd; project globs to the project root (the dir
@@ -55,7 +55,7 @@ import { matchesPathRules } from './internal/path-rules.js'
55
55
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
56
56
  import { type InstalledPlugin, installedPlugins, pluginComponentPath } from './internal/plugins.js'
57
57
  import { isProjectApproved } from './internal/project-approval.js'
58
- import { ancestorDirs, repoRoot } from './internal/project-root.js'
58
+ import { ancestorDirs, checkoutRoot } from './internal/project-root.js'
59
59
  import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from './internal/scope-rules.js'
60
60
  import { claudeSettingsChain } from './internal/settings-chain.js'
61
61
  import { fileToolTarget } from './internal/tool-target.js'
@@ -206,7 +206,7 @@ function commandVars(ctx: { cwd: string }, filePath: string, plugin?: CommandPlu
206
206
  // low|medium|high|xhigh|max, and an unset one stays literal in the body.
207
207
  CLAUDE_EFFORT: claudeEffortLevel(varCtx.thinkingLevel),
208
208
  CLAUDE_SKILL_DIR: path.dirname(filePath),
209
- CLAUDE_PROJECT_DIR: repoRoot(ctx.cwd) ?? ctx.cwd,
209
+ CLAUDE_PROJECT_DIR: checkoutRoot(ctx.cwd),
210
210
  CLAUDE_PLUGIN_ROOT: plugin?.root,
211
211
  CLAUDE_PLUGIN_DATA: plugin?.dataDir,
212
212
  }
@@ -228,7 +228,7 @@ interface SpanRunner {
228
228
  * from running.
229
229
  */
230
230
  export async function expandCommand(runner: SpanRunner, parsed: ParsedCommand, args: string, ctx: { cwd: string }, filePath: string, plugin?: CommandPlugin, options?: { allowShell?: boolean }): Promise<string> {
231
- const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
231
+ const projectRoot = checkoutRoot(ctx.cwd)
232
232
  const vars = commandVars(ctx, filePath, plugin)
233
233
  const { text: withArgs, consumed } = substituteArgsDetailed(parsed.body, args, parsed.argumentNames ?? [])
234
234
  // `${user_config.KEY}` is a plugin-command variable only; leave it literal in an
@@ -415,7 +415,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
415
415
  // pi's read/edit/write accept `file_path` as an alias for `path`; the shared reader
416
416
  // handles both, and the paired guard in hooks/matcher.ts reads both too.
417
417
  const filePath = fileToolTarget(event) ?? ''
418
- const anchors = { cwd: ctx.cwd, projectRoot: repoRoot(ctx.cwd) ?? ctx.cwd, home: os.homedir() }
418
+ const anchors = { cwd: ctx.cwd, projectRoot: checkoutRoot(ctx.cwd), home: os.homedir() }
419
419
  if (filePath && matchesPathRules(filePath, rules, anchors)) return
420
420
  return {
421
421
  block: true,
@@ -76,7 +76,7 @@ import { managedSettingsPath, readManagedSettings } from './internal/managed-set
76
76
  import { capForContext, sliceBytes } from './internal/output-guard.js'
77
77
  import { globToRegExpSource } from './internal/path-rules.js'
78
78
  import { isGatedFileApproved, isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
79
- import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
79
+ import { ancestorFiles, checkoutRoot, findNearestFile, repoRoot, sameLocation } from './internal/project-root.js'
80
80
  import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
81
81
  import { statToken } from './internal/stat-token.js'
82
82
  import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
@@ -293,7 +293,10 @@ export function rootsForImporter(importer: string, home: string, cwd: string, ex
293
293
  // granting the importer's own directory. pi also loads home-level context
294
294
  // files (~/AGENTS.md) that sit outside the config roots; their directory is
295
295
  // $HOME, and allowing it would let @.ssh/... read into every session's prompt.
296
- return realRoots([cwd, repoRoot(cwd) ?? cwd])
296
+ // The checkout the session runs in, and the main checkout too: in a worktree they are
297
+ // siblings, and the checkout's own files (docs/, a sibling CLAUDE.md) are as much the
298
+ // project as the shared root a CLAUDE.local.md may live at.
299
+ return realRoots([cwd, checkoutRoot(cwd), repoRoot(cwd) ?? cwd])
297
300
  }
298
301
 
299
302
  /** Claude's env gate for loading memory files from --add-dir directories. */
@@ -972,7 +975,11 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
972
975
  // ./.claude/CLAUDE.md (nearest at or above cwd) is repo-controlled too, so both
973
976
  // ride the one approval decision.
974
977
  const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
975
- const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
978
+ const nearestDotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
979
+ // From $HOME (or under a dotfiles repo rooted there) the nearest one is the user's own
980
+ // ~/.claude/CLAUDE.md, already loaded above as User memory: not a project file, so no
981
+ // second block and nothing to approve.
982
+ const dotClaudeMd = nearestDotClaudeMd !== null && sameLocation(nearestDotClaudeMd, userClaudeMd) ? null : nearestDotClaudeMd
976
983
  if ((candidates.length === 0 && dotClaudeMd === null) || !(await isProjectApproved(ctx))) return memory
977
984
 
978
985
  for (const candidate of candidates) {
@@ -1029,7 +1036,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
1029
1036
 
1030
1037
  if (envCache?.cwd !== cwd) {
1031
1038
  const managedNow = readManagedSettings()
1032
- envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: repoRoot(cwd) ?? cwd }
1039
+ envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: checkoutRoot(cwd) }
1033
1040
  }
1034
1041
  const { managed, excludeGlobs, projectRoot } = envCache
1035
1042
  // CLAUDE_CODE_DISABLE_CLAUDE_MDS also covers pi's own auto-discovered native context
@@ -1136,7 +1143,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
1136
1143
  if (touchedDir === undefined) return
1137
1144
 
1138
1145
  const home = os.homedir()
1139
- const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
1146
+ const projectRoot = checkoutRoot(ctx.cwd)
1140
1147
  const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(ctx.cwd, home, projectApproved), readManagedSettings())
1141
1148
  const load: NestedLoadContext = { home, cwd: ctx.cwd, realCwd, projectRoot, excludeGlobs, touched, launchLoaded: launchLoadedPaths }
1142
1149
  const { bodies, events } = nestedContextAttachments(touchedDir, load, nestedLoaded, gatedFilesApproved)
@@ -104,7 +104,7 @@ import { resolveModelOverride } from '../internal/model-lookup.js'
104
104
  import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
105
105
  import { installedPlugins, managedForceEnabled } from '../internal/plugins.js'
106
106
  import { approvalRecheck, isProjectApproved } from '../internal/project-approval.js'
107
- import { repoRoot } from '../internal/project-root.js'
107
+ import { checkoutRoot } from '../internal/project-root.js'
108
108
  import { watchSettingsFiles } from '../internal/settings-watch.js'
109
109
  import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
110
110
  import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
@@ -457,7 +457,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
457
457
  // Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
458
458
  // referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
459
459
  // subdirectory session too.
460
- projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
460
+ projectDir = checkoutRoot(ctx.cwd)
461
461
  resolveConfig(ctx.cwd, trusted)
462
462
  // Claude picks up direct settings edits mid-session via a file watcher. The reload
463
463
  // closes over the cwd value, never ctx: the poll has no awaiter, and every getter of
@@ -23,7 +23,7 @@ export { CONTEXT_FILE_CANDIDATES }
23
23
 
24
24
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
25
25
 
26
- import { repoRoot } from './internal/project-root.js'
26
+ import { checkoutRoot } from './internal/project-root.js'
27
27
 
28
28
  function statOf(target: string): fs.Stats | undefined {
29
29
  try {
@@ -73,7 +73,7 @@ export default function initExtension(pi: ExtensionAPI) {
73
73
  pi.registerCommand('init', {
74
74
  description: 'Analyze the codebase and create or improve the project context file (AGENTS.md)',
75
75
  handler: async (_args, ctx) => {
76
- const root = repoRoot(ctx.cwd) ?? ctx.cwd
76
+ const root = checkoutRoot(ctx.cwd)
77
77
  const existing = findExistingContextFile(root)
78
78
  const cursorRules = statOf(path.join(root, '.cursor', 'rules'))?.isDirectory() === true || statOf(path.join(root, '.cursorrules'))?.isFile() === true
79
79
  const copilotRules = statOf(path.join(root, '.github', 'copilot-instructions.md'))?.isFile() === true
@@ -29,6 +29,29 @@ export function repoRoot(from: string): string | undefined {
29
29
  return mainCheckout(root)
30
30
  }
31
31
 
32
+ /** The root of the checkout a session runs in, or `from` itself outside one. Claude's
33
+ * `CLAUDE_PROJECT_DIR` is "the project root where the session started", and `/`-rooted path
34
+ * rules anchor there. In a worktree that is the worktree, not the main checkout repoRoot
35
+ * resolves to: right for shared state (settings.local.json, auto memory), wrong here, where
36
+ * a hook script or a rule anchor must land in the tree the session is editing. */
37
+ export function checkoutRoot(from: string): string {
38
+ return gitRoot(from) ?? from
39
+ }
40
+
41
+ /** Whether two paths are the same file or directory once symlinks resolve. A session at
42
+ * `$HOME` finds the user's own `~/.claude/{rules,agents,CLAUDE.md}` as its "project" ones;
43
+ * comparing by realpath also catches a stow-style symlinked `~/.claude`. */
44
+ export function sameLocation(a: string, b: string): boolean {
45
+ const resolve = (target: string): string => {
46
+ try {
47
+ return fs.realpathSync(target)
48
+ } catch {
49
+ return path.resolve(target)
50
+ }
51
+ }
52
+ return resolve(a) === resolve(b)
53
+ }
54
+
32
55
  /** The git checkout at or above `from`, or undefined outside one.
33
56
  *
34
57
  * Narrower than repoRoot on purpose: repoRoot resolves a worktree to its main checkout,
@@ -42,7 +42,7 @@ import { setMcpToolCaller } from '../internal/mcp-call.js'
42
42
  import { capForContext } from '../internal/output-guard.js'
43
43
  import { installedPlugins } from '../internal/plugins.js'
44
44
  import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
45
- import { repoRoot } from '../internal/project-root.js'
45
+ import { checkoutRoot } from '../internal/project-root.js'
46
46
  import { claudeSettingsChain } from '../internal/settings-chain.js'
47
47
  import { errorMessage } from '../internal/values.js'
48
48
  import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope, localScopeServerNames, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
@@ -539,7 +539,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
539
539
  // Plugin servers merge under the user scope (plugins are user-installed);
540
540
  // the user's own entry wins a name clash with a plugin's. A server toggled off
541
541
  // in ~/.claude.json's per-project disabledMcpServers list never connects.
542
- const pluginServers = loadPluginServers(installedPlugins(os.homedir()), repoRoot(ctx.cwd) ?? ctx.cwd)
542
+ const pluginServers = loadPluginServers(installedPlugins(os.homedir()), checkoutRoot(ctx.cwd))
543
543
  const disabled = disabledServerNames(os.homedir(), ctx.cwd)
544
544
  const merged = Object.fromEntries(Object.entries({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }).filter(([name]) => !disabled.has(name)))
545
545
  const scoped = applyServerPolicy(merged, policy)
@@ -622,7 +622,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
622
622
  projectConnected = false
623
623
  // Claude answers roots/list with the session's launch directory and exports the
624
624
  // project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
625
- sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd, sessionId: ctx.sessionManager?.getSessionId?.() }
625
+ sessionDirs = { projectDir: checkoutRoot(ctx.cwd), launchDir: ctx.cwd, sessionId: ctx.sessionManager?.getSessionId?.() }
626
626
  const authUi = authUiFor(ctx)
627
627
  sessionAuthUi = authUi
628
628
  // The allow/deny lists filter every scope, including a managed-mcp.json set. They
@@ -36,7 +36,7 @@ import { claudeEffortLevel } from './internal/effort.js'
36
36
  import { readManagedSettings } from './internal/managed-settings.js'
37
37
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
38
38
  import { approvalRecheck, isProjectApprovedSilently } from './internal/project-approval.js'
39
- import { gitRoot, repoRoot } from './internal/project-root.js'
39
+ import { checkoutRoot, gitRoot } from './internal/project-root.js'
40
40
  import { readSettingsChain } from './internal/settings-chain.js'
41
41
  import { watchSettingsFiles } from './internal/settings-watch.js'
42
42
  import { readActiveStyleName, settingsFiles } from './output-styles.js'
@@ -283,7 +283,7 @@ export default function statusLine(pi: ExtensionAPI) {
283
283
  // directory. added_dirs comes from the --add-dir flag pi-code registers.
284
284
  workspace: {
285
285
  current_dir: ctx.cwd,
286
- project_dir: repoRoot(ctx.cwd) ?? ctx.cwd,
286
+ project_dir: checkoutRoot(ctx.cwd),
287
287
  git_worktree: isGitWorktree(ctx.cwd),
288
288
  added_dirs: addedDirs(),
289
289
  },
@@ -14,7 +14,7 @@ import { claudeConfigDir } from '../internal/config-dir.js'
14
14
  import { parseClaudeFrontmatter } from '../internal/frontmatter.js'
15
15
  import { findModel } from '../internal/model-lookup.js'
16
16
  import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
17
- import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
17
+ import { ancestorDirs, findNearestDir, sameLocation } from '../internal/project-root.js'
18
18
  import { errorMessage } from '../internal/values.js'
19
19
 
20
20
  /**
@@ -371,7 +371,12 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
371
371
  // Claude scans every .claude/agents between cwd and the repository root, the
372
372
  // definition closest to cwd winning a name clash; root-first load order makes
373
373
  // the nearer directory's entry overwrite in the map below.
374
- const projectClaudeDirs = ancestorDirs(cwd, path.join('.claude', 'agents')).reverse()
374
+ // The user's own directory is not a project one: at $HOME, or under a dotfiles repo
375
+ // rooted there, the walk reaches ~/.claude/agents and would gate the user's agents as
376
+ // repo-controlled.
377
+ const projectClaudeDirs = ancestorDirs(cwd, path.join('.claude', 'agents'))
378
+ .filter((dir) => !sameLocation(dir, claudeUserDir))
379
+ .reverse()
375
380
 
376
381
  // Plugins load after builtins and before the user's own dirs, so a user agent
377
382
  // wins a name clash with a plugin's, and ~/.pi/agent/agents wins over ~/.claude.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.72",
3
+ "version": "1.0.73",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",