pi-code 1.0.60 → 1.0.61

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.
@@ -111,14 +111,15 @@ export function parseFrontmatter(content: string): Frontmatter {
111
111
  }
112
112
 
113
113
  /**
114
- * Whether a file path matches at least one of a rule's `paths:` globs. `*` stays in
115
- * a segment, `**` crosses directories, a slashless pattern (`*.ts`) matches the
116
- * basename at any depth (gitignore-style), and a trailing slash (`docs/`) scopes to
117
- * that directory's contents. `./` and a leading `/` are stripped so a project-root
118
- * anchored glob resolves the same as a bare one. A bare directory name without a
119
- * trailing slash or `**` matches a file of that name, not the directory's contents;
120
- * write `dir/**` to scope to a directory. `relPath` is the touched file relative to
121
- * the rule set's root.
114
+ * Whether a file path matches at least one of a rule's `paths:` globs. `*` stays in a
115
+ * segment and `**` crosses directories. A slashless pattern (`*.ts`, `README.md`) is
116
+ * the project root, per Claude's table; a leading globstar segment is how a rule says
117
+ * any depth. A trailing slash (`docs/`) scopes to that directory's contents. `./` and
118
+ * a leading `/` are stripped so a project-root anchored glob resolves the same as a
119
+ * bare one. A bare directory name without a trailing slash or a globstar matches a
120
+ * file of that name, not the directory's contents; write `dir` followed by a globstar
121
+ * to scope to a directory. `relPath` is the touched file relative to the rule set's
122
+ * root.
122
123
  */
123
124
  export function pathMatchesGlobs(relPath: string, globs: string[]): boolean {
124
125
  return matchesCompiledGlobs(relPath, compileGlobs(globs))
@@ -188,7 +188,6 @@ function resolveRule(rule: string, anchors: PathAnchors): string {
188
188
  * root-relative path. */
189
189
  export interface CompiledGlob {
190
190
  regex: RegExp
191
- matchesBasename: boolean
192
191
  }
193
192
 
194
193
  let globsCompiled = 0
@@ -219,7 +218,7 @@ export function compileGlobs(globs: string[]): CompiledGlob[] {
219
218
  // would compile to `^docs/$` and match nothing.
220
219
  if (glob.endsWith('/')) glob += '**'
221
220
  globsCompiled += 1
222
- compiled.push({ regex: new RegExp(`^${globToRegExpSource(glob)}$`), matchesBasename: !glob.includes('/') })
221
+ compiled.push({ regex: new RegExp(`^${globToRegExpSource(glob)}$`) })
223
222
  }
224
223
  return compiled
225
224
  }
@@ -229,8 +228,7 @@ export function compileGlobs(globs: string[]): CompiledGlob[] {
229
228
  export function matchesCompiledGlobs(relPath: string, globs: CompiledGlob[]): boolean {
230
229
  globsEvaluated += 1
231
230
  const posix = relPath.split(path.sep).join('/')
232
- const base = posix.split('/').pop() ?? posix
233
- return globs.some((glob) => glob.regex.test(glob.matchesBasename ? base : posix))
231
+ return globs.some((glob) => glob.regex.test(posix))
234
232
  }
235
233
 
236
234
  /** Whether the accessed file matches at least one rule. No rules means no match:
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import * as fs from 'node:fs'
21
+ import * as os from 'node:os'
21
22
  import * as path from 'node:path'
22
23
  import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
23
24
 
@@ -46,11 +47,15 @@ const CLAUDE_SHAPED = [
46
47
  * The walk matters: agent discovery already searches upward, so starting pi in a
47
48
  * subdirectory of a repository whose `.claude/agents` sits at the root found those
48
49
  * agents while a cwd-only check reported nothing to gate, and the short-circuit
49
- * approved the project without ever asking. The bound is the repository root, so a
50
- * directory outside any repository never inherits a parent's config. */
51
- export function hasClaudeShapedConfig(cwd: string): boolean {
50
+ * approved the project without ever asking. The bound is the repository root and the home
51
+ * directory, because `~/.claude` is the user's own configuration: a directory under
52
+ * home that is in no repository would otherwise walk up into it and report the user's
53
+ * own settings as a project waiting to be approved. */
54
+ export function hasClaudeShapedConfig(cwd: string, home: string = os.homedir()): boolean {
52
55
  let currentDir = cwd
53
56
  while (true) {
57
+ // The home check comes first: at home itself the .claude found is the user's own.
58
+ if (currentDir === home) return false
54
59
  if (CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(currentDir, entry)))) return true
55
60
  if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return false
56
61
  const parentDir = path.dirname(currentDir)
@@ -13,18 +13,21 @@
13
13
  import * as fs from 'node:fs'
14
14
  import * as path from 'node:path'
15
15
 
16
- /** Project root markers ending the walk. `.git` is a file in worktrees and submodules. */
17
- export const ROOT_MARKERS = ['.git', 'package.json']
16
+ /** The project root marker. `.git` is a file in worktrees and submodules, a directory
17
+ * in an ordinary clone.
18
+ *
19
+ * `package.json` used to count too, which made every package of a monorepo its own
20
+ * project: its own memory directory, its own settings.local.json, its own
21
+ * CLAUDE_PROJECT_DIR, its own trust decision. Claude's project is the repository, and
22
+ * a repository can add a package.json wherever it likes, so a marker it controls was
23
+ * also a marker it could move. */
24
+ export const ROOT_MARKERS = ['.git']
18
25
 
19
- /** Project root at or above `from`, or undefined when no marker is found. */
26
+ /** Project root at or above `from`, or undefined outside a repository. */
20
27
  export function repoRoot(from: string): string | undefined {
21
- let currentDir = from
22
- while (true) {
23
- if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return currentDir
24
- const parentDir = path.dirname(currentDir)
25
- if (parentDir === currentDir) return undefined
26
- currentDir = parentDir
27
- }
28
+ const root = gitRoot(from)
29
+ if (root === undefined) return undefined
30
+ return mainCheckout(root)
28
31
  }
29
32
 
30
33
  /** The git checkout at or above `from`, or undefined outside one.
@@ -45,6 +48,38 @@ export function gitRoot(from: string): string | undefined {
45
48
  }
46
49
  }
47
50
 
51
+ /** The main checkout for a git directory.
52
+ *
53
+ * A worktree carries a `.git` FILE holding `gitdir: <main>/.git/worktrees/<name>`, so
54
+ * resolving it gives the checkout the repository's state actually belongs to. Claude
55
+ * reads settings.local.json from "the file at the main checkout's root" and shares one
56
+ * auto memory directory across "all worktrees and subdirectories within the same repo",
57
+ * so a worktree is not its own project. An unreadable or unexpected `.git` file leaves
58
+ * the directory as its own root, which is the safe direction. */
59
+ function mainCheckout(root: string): string {
60
+ const dotGit = path.join(root, '.git')
61
+ let pointer: string
62
+ try {
63
+ if (!fs.statSync(dotGit).isFile()) return root
64
+ pointer = fs.readFileSync(dotGit, 'utf-8')
65
+ } catch {
66
+ return root
67
+ }
68
+ // Parsed rather than matched: the file is one `gitdir: <path>` line, and a regex
69
+ // over an arbitrary-length path is a backtracking cost for nothing.
70
+ const [firstLine = ''] = pointer.split('\n')
71
+ const prefix = 'gitdir:'
72
+ if (!firstLine.startsWith(prefix)) return root
73
+ const target = firstLine.slice(prefix.length).trim()
74
+ if (!target) return root
75
+ // <main>/.git/worktrees/<name> -> <main>
76
+ const worktreeDir = path.resolve(root, target)
77
+ const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`
78
+ const cut = worktreeDir.lastIndexOf(marker)
79
+ if (cut === -1) return root
80
+ return worktreeDir.slice(0, cut)
81
+ }
82
+
48
83
  function statOf(target: string): fs.Stats | null {
49
84
  try {
50
85
  return fs.statSync(target)
@@ -13,19 +13,46 @@ import { claudeConfigDir } from './config-dir.js'
13
13
  import { repoRoot } from './project-root.js'
14
14
  import { isRecord } from './values.js'
15
15
 
16
+ /** Whether every path given exists and belongs to the user running this process.
17
+ * A path that is absent is not someone else's, so it does not disqualify the root. */
18
+ function ownedByUser(paths: string[]): boolean {
19
+ const uid = process.getuid?.()
20
+ if (uid === undefined) return true
21
+ return paths.every((target) => {
22
+ try {
23
+ return fs.statSync(target).uid === uid
24
+ } catch {
25
+ return true
26
+ }
27
+ })
28
+ }
29
+
30
+ /** Where `settings.local.json` lives, per Claude's four exceptions: it sits at the
31
+ * repository root, except outside a repository, when that root is the home directory,
32
+ * on Windows, or when the root or its `.git` or `.claude` entry belongs to someone
33
+ * else. In each of those it stays beside `.claude/settings.json` in the working
34
+ * directory instead. In a worktree the root is the main checkout, which repoRoot
35
+ * resolves. */
36
+ function localSettingsDir(cwd: string, home: string, platform: NodeJS.Platform, owned: (paths: string[]) => boolean): string {
37
+ const root = repoRoot(cwd)
38
+ if (root === undefined || root === home) return cwd
39
+ if (platform === 'win32') return cwd
40
+ if (!owned([root, path.join(root, '.git'), path.join(root, '.claude')])) return cwd
41
+ return root
42
+ }
43
+
16
44
  /** The user settings.json, then (only when `includeProject`) the project files by
17
45
  * Claude's placement rules: the shared `.claude/settings.json` is read from the
18
46
  * session's primary working directory (never an ancestor; "to use a file committed
19
47
  * at the repository root, start Claude Code there"), while `settings.local.json`
20
- * lives at the repository root, falling back to the primary directory outside a
21
- * repository or when the root is the home directory. A legacy local file at the
22
- * primary directory is still read, with the root's values winning. Later files win. */
23
- export function claudeSettingsChain(cwd: string, home: string, includeProject: boolean): string[] {
48
+ * lives at the repository root, subject to the exceptions in localSettingsDir. A
49
+ * legacy local file at the primary directory is still read, with the root's values
50
+ * winning. Later files win. */
51
+ export function claudeSettingsChain(cwd: string, home: string, includeProject: boolean, platform: NodeJS.Platform = process.platform, owned: (paths: string[]) => boolean = ownedByUser): string[] {
24
52
  const files = [path.join(claudeConfigDir(home), 'settings.json')]
25
53
  if (!includeProject) return files
26
54
  files.push(path.join(cwd, '.claude', 'settings.json'))
27
- const root = repoRoot(cwd)
28
- const localDir = root !== undefined && root !== home ? root : cwd
55
+ const localDir = localSettingsDir(cwd, home, platform, owned)
29
56
  if (localDir !== cwd) files.push(path.join(cwd, '.claude', 'settings.local.json'))
30
57
  files.push(path.join(localDir, '.claude', 'settings.local.json'))
31
58
  return files
@@ -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 { isProjectApprovedSilently } from './internal/project-approval.js'
39
- import { repoRoot } from './internal/project-root.js'
39
+ import { gitRoot, repoRoot } 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'
@@ -47,7 +47,9 @@ const DEBOUNCE_MS = 300
47
47
  /** Whether cwd sits in a git worktree: `.git` is a file pointing at the main
48
48
  * checkout there, and a directory in an ordinary clone. */
49
49
  function isGitWorktree(cwd: string): boolean {
50
- const root = repoRoot(cwd)
50
+ // gitRoot, not repoRoot: repoRoot resolves a worktree to its main checkout, which is
51
+ // exactly the thing this needs to see before it is resolved away.
52
+ const root = gitRoot(cwd)
51
53
  if (root === undefined) return false
52
54
  try {
53
55
  return fs.statSync(path.join(root, '.git')).isFile()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.60",
3
+ "version": "1.0.61",
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",