pi-code 1.0.57 → 1.0.58

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.
@@ -29,6 +29,7 @@ import { type CompiledGlob, compileGlobs, matchesCompiledGlobs } from './interna
29
29
  import { isProjectApproved } from './internal/project-approval.js'
30
30
  import { findNearestDir } from './internal/project-root.js'
31
31
  import { stripBlockComments } from './internal/strip-comments.js'
32
+ import { fileToolTarget } from './internal/tool-target.js'
32
33
 
33
34
  export interface Frontmatter {
34
35
  paths: string[]
@@ -341,10 +342,8 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
341
342
  // read or edited rather than inlining it upfront.
342
343
  pi.on('tool_result', async (event, ctx) => {
343
344
  if (attachTargets.length === 0) return
344
- if (event.isError) return
345
- if (event.toolName !== 'read' && event.toolName !== 'edit' && event.toolName !== 'write') return
346
- const rel = (event.input as { path?: unknown } | undefined)?.path
347
- if (typeof rel !== 'string' || rel.length === 0) return
345
+ const rel = fileToolTarget(event)
346
+ if (rel === undefined) return
348
347
  // Realpath both sides (roots canonicalise at session_start): a tool reporting
349
348
  // the resolved real path in a symlinked checkout must still match.
350
349
  const abs = realpathOr(path.resolve(ctx.cwd, rel))
@@ -72,13 +72,14 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
72
72
  import { claudeConfigDir } from './internal/config-dir.js'
73
73
  import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
74
74
  import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
75
- import { sliceBytes } from './internal/output-guard.js'
75
+ import { capForContext, sliceBytes } from './internal/output-guard.js'
76
76
  import { globToRegExpSource } from './internal/path-rules.js'
77
- import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
77
+ import { isGatedFileApproved, isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
78
78
  import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
79
79
  import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
80
80
  import { statToken } from './internal/stat-token.js'
81
81
  import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
82
+ import { fileToolTarget } from './internal/tool-target.js'
82
83
 
83
84
  /** Claude documents "a maximum depth of four hops" for recursive imports. */
84
85
  const MAX_IMPORT_DEPTH = 4
@@ -138,9 +139,12 @@ export interface ImportBudget {
138
139
  files: number
139
140
  bytes: number
140
141
  dropped: number
142
+ /** Existing files an @import named that resolve outside the importer's allowed
143
+ * roots. Collected so the refusal can be reported rather than left silent. */
144
+ refused: Set<string>
141
145
  }
142
146
 
143
- export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0 })
147
+ export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0, refused: new Set() })
144
148
 
145
149
  /** The `@path` targets of a context file, in document order. Claude Code evaluates
146
150
  * imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
@@ -162,16 +166,28 @@ function importTargets(content: string): string[] {
162
166
  }
163
167
 
164
168
  /** Read one `@path` target, or null when it is unresolvable, already seen, outside `allowedRoots`, excluded, or unreadable. */
165
- function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded?: (realPath: string) => boolean): { real: string; body: string } | null {
169
+ function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded: ((realPath: string) => boolean) | undefined, refused: Set<string>): { real: string; body: string } | null {
166
170
  const resolved = path.resolve(fromDir, expandHome(target, home))
171
+ // Always the path the importing file named, never where a symlink pointed: the
172
+ // notice would otherwise hand a repo the real name of whatever the link reaches,
173
+ // which is the disclosure the refusal exists to prevent. An excluded file is not
174
+ // named either, since exclusion removes it from every other surface too.
175
+ const refuse = (): null => {
176
+ if (isExcluded?.(resolved) !== true) refused.add(resolved)
177
+ return null
178
+ }
167
179
  let real: string
168
180
  try {
169
181
  real = fs.realpathSync(resolved)
170
182
  } catch {
171
- return null
183
+ // Not on disk. Still refused when it points outside, so that whether a path is
184
+ // reported never depends on whether it exists: a notice that named only the
185
+ // existing ones would enumerate the filesystem for any repo-controlled file
186
+ // willing to write one @line per guess.
187
+ return isUnder(resolved, allowedRoots) ? null : refuse()
172
188
  }
173
189
  if (seen.has(real)) return null
174
- if (!isUnder(real, allowedRoots)) return null
190
+ if (!isUnder(real, allowedRoots)) return refuse()
175
191
  // Checked before the read so an excluded file contributes nothing: no body, no
176
192
  // transitive imports, no budget spend, no announce. A post-collection filter
177
193
  // would drop the file itself but keep its children.
@@ -182,6 +198,9 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
182
198
  // Only a consumed file dedupes: marking a blocked or unreadable target seen
183
199
  // would let one reader's failure suppress the import for a later, allowed one.
184
200
  seen.add(real)
201
+ // A file another importer already refused is in context after all; the notice
202
+ // must not claim otherwise.
203
+ refused.delete(resolved)
185
204
  return { real, body }
186
205
  } catch {
187
206
  return null
@@ -218,7 +237,7 @@ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth:
218
237
  scan.budget.dropped += 1
219
238
  continue
220
239
  }
221
- const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
240
+ const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded, scan.budget.refused)
222
241
  if (!file) continue
223
242
  scan.budget.files -= 1
224
243
  // The budget is bytes: a string slice counts UTF-16 units and lets CJK text through
@@ -497,6 +516,156 @@ function expandImports(contextFiles: Array<{ path: string; content: string }>, e
497
516
  return imported
498
517
  }
499
518
 
519
+ /** Every context file that could load on demand for a touched directory, shallowest
520
+ * first, so the deepest instructions are read last as they are at launch. */
521
+ function* nestedCandidates(touchedDir: string, realCwd: string): Generator<{ file: string; dir: string; name: string }> {
522
+ for (const dir of nestedContextDirs(touchedDir, realCwd)) {
523
+ for (const name of NESTED_CONTEXT_NAMES) yield { file: path.join(dir, name), dir, name }
524
+ }
525
+ }
526
+
527
+ /** Everything the on-demand load needs from the session and the tool call, grouped so
528
+ * the per-file worker stays a three-argument function. */
529
+ interface NestedLoadContext {
530
+ home: string
531
+ cwd: string
532
+ realCwd: string
533
+ projectRoot: string
534
+ excludeGlobs: string[]
535
+ /** The file whose access triggered the load, for trigger_file_path. */
536
+ touched: string
537
+ /** Paths already in the system prompt, so a nested @import does not repeat one. */
538
+ launchLoaded: string[]
539
+ }
540
+
541
+ /** One nested context file's block and the instruction loads it should announce, or
542
+ * nothing to attach: absent, excluded, empty, or reaching outside the project. `read`
543
+ * reports whether the file was there at all, so a file that exists is only ever
544
+ * attached once while a missing one can still appear later in the session. */
545
+ function nestedContextBlock(file: string, dir: string, load: NestedLoadContext): { read: boolean; text?: string; events?: InstructionLoadEvent[] } {
546
+ if (isExcludedPath(file, load.excludeGlobs, load.home)) return { read: false }
547
+ // The file itself may be a link out of the project, whatever its directory is.
548
+ const [real] = realRoots([file])
549
+ if (real === undefined || !isUnder(real, [load.realCwd])) return { read: false }
550
+ const content = readContextFile(real)
551
+ if (content === undefined) return { read: false }
552
+ const body = stripBlockComments(content).trim()
553
+ if (body.length === 0) return { read: true }
554
+ // Its own @imports resolve at project roots, on a budget of their own: this load is
555
+ // outside the launch-time expansion the shared budget covers. The launch-time paths
556
+ // seed the seen set, so a nested file importing the root CLAUDE.md does not pay for
557
+ // a body already in the system prompt.
558
+ const seen = new Set([...load.launchLoaded, real])
559
+ const imports = collectImports(content, dir, load.home, rootsForImporter(real, load.home, load.cwd), seen, {
560
+ importer: real,
561
+ isExcluded: (absPath) => isExcludedPath(absPath, load.excludeGlobs, load.home),
562
+ budget: createImportBudget(),
563
+ })
564
+ return {
565
+ read: true,
566
+ text: [instructionsBlock(file, body), ...imports.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`)].join('\n\n'),
567
+ events: [
568
+ { file_path: file, memory_type: memoryTypeForPath(file, load.home, load.projectRoot), load_reason: 'nested_traversal', trigger_file_path: load.touched },
569
+ ...imports.map((entry) => ({
570
+ file_path: entry.path,
571
+ memory_type: memoryTypeForPath(entry.path, load.home, load.projectRoot),
572
+ load_reason: 'include',
573
+ parent_file_path: file,
574
+ })),
575
+ ],
576
+ }
577
+ }
578
+
579
+ /** The context files Claude loads on demand rather than at launch, in the order it
580
+ * reads them within a directory. */
581
+ const NESTED_CONTEXT_NAMES = ['CLAUDE.md', 'CLAUDE.local.md'] as const
582
+
583
+ /** The directories between a touched file and cwd, nearest cwd first.
584
+ *
585
+ * Claude loads CLAUDE.md from cwd and every directory above it at launch, and the
586
+ * ones below "are included when Claude reads files in those directories"
587
+ * (memory.md). Only the strictly-below range belongs here; cwd's own file is
588
+ * already in the prompt. A file outside cwd contributes nothing.
589
+ */
590
+ function nestedContextDirs(from: string, cwd: string): string[] {
591
+ const dirs: string[] = []
592
+ let current = from
593
+ while (current !== cwd && current !== path.dirname(current)) {
594
+ dirs.unshift(current)
595
+ current = path.dirname(current)
596
+ }
597
+ // The walk reached the filesystem root without meeting cwd, so the file is outside
598
+ // it: nothing below cwd to load. A file in cwd itself ends the loop with no dirs.
599
+ return current === cwd ? dirs : []
600
+ }
601
+
602
+ /** The context-file names pi prefers over CLAUDE.md in the same directory. Mirrors
603
+ * pi's own lookup order (init.ts CONTEXT_FILE_CANDIDATES); the sibling search below
604
+ * keys off what pi actually loaded, so this is only used to recognize those files. */
605
+ const AGENTS_FILE_NAMES = new Set(['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD'])
606
+
607
+ /** The blocks and instruction loads for one touched directory. `loaded` is the
608
+ * session's set of already-attached files and is updated in place, so a file that
609
+ * exists is attached once and a missing one can still appear later. */
610
+ function nestedContextAttachments(touchedDir: string, load: NestedLoadContext, loaded: Set<string>, localsApproved: boolean): { bodies: string[]; events: InstructionLoadEvent[] } {
611
+ const bodies: string[] = []
612
+ const events: InstructionLoadEvent[] = []
613
+ for (const { file, dir, name } of nestedCandidates(touchedDir, load.realCwd)) {
614
+ if (loaded.has(file)) continue
615
+ // A CLAUDE.local.md needs a decision, never the "nothing here to gate" shortcut:
616
+ // the approval walk only looks at or above cwd, so this is the one door it cannot
617
+ // see (see isGatedFileApproved).
618
+ if (name === 'CLAUDE.local.md' && !localsApproved) continue
619
+ const block = nestedContextBlock(file, dir, load)
620
+ if (block.read) loaded.add(file)
621
+ if (block.text === undefined) continue
622
+ bodies.push(block.text)
623
+ events.push(...(block.events ?? []))
624
+ }
625
+ return { bodies, events }
626
+ }
627
+
628
+ /** The CLAUDE.md files pi passed over.
629
+ *
630
+ * Claude Code reads CLAUDE.md and never AGENTS.md, and its documented recipe for a
631
+ * repository that already has an AGENTS.md is a CLAUDE.md that imports it and adds
632
+ * Claude-specific instructions below (memory.md, "AGENTS.md"). pi loads one context
633
+ * file per directory and prefers AGENTS.md, so on exactly that layout the
634
+ * Claude-specific half never reaches the prompt. Each one found here is loaded like
635
+ * any other project file: exclude-checked, comment-stripped, imports expanded.
636
+ *
637
+ * The `@AGENTS.md` the recipe opens with costs nothing: pi's own file paths already
638
+ * seed the import seen-set, so the body it names is not injected a second time. */
639
+ function siblingClaudeMdFiles(native: Array<{ path: string; content: string }>): Array<{ path: string; content: string }> {
640
+ const loaded = new Set(realRoots(native.map((file) => file.path)))
641
+ const found: Array<{ path: string; content: string }> = []
642
+ for (const file of native) {
643
+ if (!AGENTS_FILE_NAMES.has(path.basename(file.path))) continue
644
+ // CLAUDE.md only: pi also answers to CLAUDE.MD, but Claude Code reads the one
645
+ // spelling, and on a case-insensitive filesystem looking for both finds the same
646
+ // file twice under two names.
647
+ const candidate = path.join(path.dirname(file.path), 'CLAUDE.md')
648
+ const [real] = realRoots([candidate])
649
+ if (real !== undefined && loaded.has(real)) continue
650
+ const content = readContextFile(candidate)
651
+ if (content === undefined) continue
652
+ found.push({ path: candidate, content })
653
+ }
654
+ return found
655
+ }
656
+
657
+ /** The CLAUDE.md files pi passed over, appended as project_instructions blocks in the
658
+ * ./CLAUDE.md slot, ahead of the ./.claude/CLAUDE.md alternate and the locals. */
659
+ function siblingClaudeMdAddition(siblings: Array<{ path: string; content: string }>, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
660
+ let addition = ''
661
+ for (const sibling of siblings) {
662
+ if (sibling.content.trim().length === 0) continue
663
+ announce({ file_path: sibling.path, memory_type: memoryTypeForPath(sibling.path, home, projectRoot), load_reason: 'session_start' })
664
+ addition += `\n\n${instructionsBlock(sibling.path, sibling.content.trim())}`
665
+ }
666
+ return addition
667
+ }
668
+
500
669
  /** The project-scope ./.claude/CLAUDE.md appended as a project_instructions block,
501
670
  * announced as it is added (only when non-empty, matching what reaches the prompt).
502
671
  * Claude reads project instructions from ./CLAUDE.md OR ./.claude/CLAUDE.md; pi loads
@@ -535,6 +704,19 @@ function additionalDirsAddition(extras: Array<{ path: string; content: string; d
535
704
  /** The `## Imported context (@)` section for every resolved @import, with the
536
705
  * budget-exhaustion notice, announcing each as an `include`. Empty when nothing
537
706
  * was imported. */
707
+ /** The refusal notice: every existing file an @import named that its importer may not
708
+ * reach. Claude asks about these through an approval dialog and loads the ones you
709
+ * allow; pi-code refuses them, and this is what says so. Silence was the real defect:
710
+ * the importing file looks loaded and its instructions are simply not there. */
711
+ function refusedImportsAddition(refused: Set<string>): string {
712
+ if (refused.size === 0) return ''
713
+ const list = [...refused]
714
+ .sort((a, b) => a.localeCompare(b, 'en'))
715
+ .map((file) => `- ${file}`)
716
+ .join('\n')
717
+ return `\n\n## Imports not loaded (@)\n\nThese files resolve outside what the file importing them may read, so their contents are not in context:\n\n${list}`
718
+ }
719
+
538
720
  function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
539
721
  if (imported.length === 0) return ''
540
722
  const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
@@ -613,9 +795,19 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
613
795
  // natively but not this one; repo-controlled, so it is approval-gated like the
614
796
  // locals and read once at session start.
615
797
  let projectDotClaude: { path: string; content: string } | undefined
798
+ // Whether a file that is itself the thing to gate may be read. Decided at session
799
+ // start like the flag below, from the session context, which is where the trust
800
+ // capability lives; a tool_result context carries no approval state.
801
+ let gatedFilesApproved = false
616
802
  // Whether project settings may contribute claudeMdExcludes; decided at session
617
803
  // start with the silent check, so no prompt fires mid-flight.
618
804
  let projectApproved = false
805
+ // Every context file the last turn put in the system prompt, so a nested file's
806
+ // @import cannot pay for a body that is already there.
807
+ let launchLoadedPaths: string[] = []
808
+ // Nested CLAUDE.md/CLAUDE.local.md files already attached this session, so a second
809
+ // read in the same subtree does not repeat them.
810
+ const nestedLoaded = new Set<string>()
619
811
  // Instruction loads already announced on the shared bus, keyed reason:path.
620
812
  // before_agent_start fires every turn, so without this a configured
621
813
  // InstructionsLoaded hook would fire once per file per turn.
@@ -660,6 +852,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
660
852
  memoKey: string,
661
853
  native: Array<{ path: string; content: string }>,
662
854
  contextFiles: Array<{ path: string; content: string }>,
855
+ siblings: Array<{ path: string; content: string }>,
663
856
  home: string,
664
857
  cwd: string,
665
858
  excluded: (absPath: string) => boolean,
@@ -672,7 +865,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
672
865
  // files are never re-imported and an excluded file cannot return as an import.
673
866
  // The user, project-.claude and managed-file additions join the seed too, so a
674
867
  // context file's @import cannot pull any of them in a second time.
675
- const ownPaths = [...native, ...localContexts].map((file) => file.path)
868
+ const ownPaths = [...native, ...localContexts, ...siblings].map((file) => file.path)
676
869
  if (userContext !== undefined) ownPaths.push(userContext.path)
677
870
  if (projectDotClaude !== undefined) ownPaths.push(projectDotClaude.path)
678
871
  ownPaths.push(managedClaudeMdPath())
@@ -749,6 +942,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
749
942
  }
750
943
  // Read after the local-context flow so an approval it just recorded is honored.
751
944
  projectApproved = isProjectApprovedSilently(ctx)
945
+ gatedFilesApproved = isGatedFileApproved(ctx)
752
946
  })
753
947
 
754
948
  pi.on('before_agent_start', async (event) => {
@@ -792,33 +986,75 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
792
986
 
793
987
  const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
794
988
 
989
+ // Repo-controlled text, so gated on the same approval as the locals and the
990
+ // ./.claude/CLAUDE.md; read here rather than at session start because only the
991
+ // turn's contextFiles say which file pi actually chose per directory.
992
+ const keptSiblings = (projectApproved ? siblingClaudeMdFiles(native) : []).filter((sibling) => !excluded(sibling.path)).map((sibling) => ({ path: sibling.path, content: stripBlockComments(sibling.content) }))
993
+
795
994
  // ./.claude/CLAUDE.md, deduped against pi's native context so that if pi ever
796
995
  // loads it too there is no double block, then exclude-checked and comment-stripped
797
996
  // like the rest. Its @imports resolve at project roots (rootsForImporter).
798
997
  const nativeReal = new Set(realRoots(native.map((file) => file.path)))
799
998
  const [dotReal] = projectDotClaude !== undefined ? realRoots([projectDotClaude.path]) : []
800
- const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && nativeReal.has(dotReal)) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
999
+ const siblingReal = new Set(realRoots(keptSiblings.map((sibling) => sibling.path)))
1000
+ const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && (nativeReal.has(dotReal) || siblingReal.has(dotReal))) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
801
1001
 
802
1002
  // The user CLAUDE.md and ./.claude/CLAUDE.md join the import-expansion set so their
803
1003
  // @imports resolve (each at roots scoped to it, via rootsForImporter); their own
804
1004
  // bodies are placed separately, so expansion only surfaces what they import.
805
- const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
1005
+ const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...keptSiblings, ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
806
1006
 
807
1007
  // Everything the expansion depends on, hashed: a turn whose inputs match the memo
808
1008
  // and whose recorded mtimes are unchanged reuses the previous expansion outright.
809
1009
  const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
810
1010
  const memoKey = buildImportMemoKey({ cwd, home, projectApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
811
1011
 
812
- const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, home, cwd, excluded)
1012
+ const { extras, budget, imported } = resolveImports(memoKey, native, contextFiles, keptSiblings, home, cwd, excluded)
1013
+ launchLoadedPaths = realRoots([...contextFiles.map((file) => file.path), ...imported.map((entry) => entry.path)])
813
1014
 
814
1015
  // Project memory precedes local memory, so the ./.claude/CLAUDE.md block leads the
815
1016
  // additions, ahead of the CLAUDE.local.md bodies.
816
- let addition = projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
1017
+ let addition = siblingClaudeMdAddition(keptSiblings, home, projectRoot, announce)
1018
+ addition += projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
817
1019
  addition += localContextAddition(keptLocals, announce)
818
1020
  addition += additionalDirsAddition(extras, announce)
819
1021
  addition += importedAddition(imported, budget, home, projectRoot, announce)
1022
+ addition += refusedImportsAddition(budget.refused)
820
1023
  if (!changed && addition.length === 0) return
821
1024
 
822
1025
  return { systemPrompt: prompt + addition }
823
1026
  })
1027
+
1028
+ // Claude's nested traversal: CLAUDE.md and CLAUDE.local.md below the working
1029
+ // directory are not loaded at launch but "are included when Claude reads files in
1030
+ // those subdirectories" (memory.md). pi's loader stops at cwd, so the whole
1031
+ // below-cwd range is missing; this attaches each one to the tool result that
1032
+ // touched its directory, which is the same seam claude-rules uses for a scoped
1033
+ // rule. Once per file per session, ordered shallowest first so the deepest
1034
+ // instructions are read last, matching the launch-time ordering.
1035
+ pi.on('tool_result', async (event, ctx) => {
1036
+ const rel = fileToolTarget(event)
1037
+ if (rel === undefined) return
1038
+ // Repo-controlled text, gated like every other project file this extension adds.
1039
+ if (!projectApproved) return
1040
+ // Realpath both sides: the walk below is lexical, so a symlinked subdirectory
1041
+ // would otherwise carry it straight out of the project.
1042
+ const [realCwd = ctx.cwd] = realRoots([ctx.cwd])
1043
+ const touched = path.resolve(ctx.cwd, rel)
1044
+ // The directory, not the file: a write creates its target, and a read of a path
1045
+ // that has since moved should still contribute what its directory holds.
1046
+ const [touchedDir] = realRoots([path.dirname(touched)])
1047
+ if (touchedDir === undefined) return
1048
+
1049
+ const home = os.homedir()
1050
+ const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
1051
+ const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(ctx.cwd, home, projectApproved), readManagedSettings())
1052
+ const load: NestedLoadContext = { home, cwd: ctx.cwd, realCwd, projectRoot, excludeGlobs, touched, launchLoaded: launchLoadedPaths }
1053
+ const { bodies, events } = nestedContextAttachments(touchedDir, load, nestedLoaded, gatedFilesApproved)
1054
+ for (const loaded of events) announce(loaded)
1055
+ if (bodies.length === 0) return
1056
+ // Capped like every other tool output: one read in a deep subtree must not be
1057
+ // able to spend the context window on memory files.
1058
+ return { content: [...event.content, { type: 'text' as const, text: capForContext(bodies.join('\n\n')) }] }
1059
+ })
824
1060
  }
@@ -131,6 +131,20 @@ export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'is
131
131
  return deps.savedDecision(ctx.cwd) === true
132
132
  }
133
133
 
134
+ /**
135
+ * The approval decision for a file that is itself the thing to gate.
136
+ *
137
+ * The silent check short-circuits to approved when the repository holds no
138
+ * Claude-shaped config, meaning there is nothing here pi-code would act on. That is
139
+ * wrong for a file the CLAUDE_SHAPED walk cannot see: the walk only looks at or above
140
+ * cwd, so a CLAUDE.local.md in a subdirectory would come in through the one door the
141
+ * gate does not cover. Forcing the shaped answer makes such a file need a real
142
+ * decision, never the shortcut.
143
+ */
144
+ export function isGatedFileApproved(ctx: Pick<ApprovalContext, 'cwd' | 'isProjectTrusted'> & { ui?: ApprovalContext['ui'] }, deps: ApprovalDeps = defaultDeps): boolean {
145
+ return isProjectApprovedSilently(ctx, { ...deps, hasClaudeShaped: () => true })
146
+ }
147
+
134
148
  export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
135
149
  if (runtimeLacksProjectTrust(ctx)) return false // pi predates project-trust support
136
150
  if (ctx.isProjectTrusted?.() !== true) return false // pi declined trust for this project
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Which file a tool call touched.
3
+ *
4
+ * Two extensions attach instruction files when a file tool touches a path they cover
5
+ * (claude-rules for a path-scoped rule, context-imports for a nested CLAUDE.md), and
6
+ * both got the same detail wrong: pi's edit and write tools accept `file_path` as an
7
+ * alias for `path`, so a handler reading only `path` did nothing for a model that used
8
+ * the alias. One reader, one place to be wrong.
9
+ */
10
+
11
+ /** The tools that name a file pi-code acts on. */
12
+ const FILE_TOOLS: ReadonlySet<string> = new Set(['read', 'edit', 'write'])
13
+
14
+ /** The path a file tool call named, or undefined for any other tool, an errored call,
15
+ * or a call that named none. The path is as the tool gave it: callers resolve it
16
+ * against their own cwd. */
17
+ export function fileToolTarget(event: { toolName: string; input?: unknown; isError?: boolean }): string | undefined {
18
+ if (event.isError === true) return undefined
19
+ if (!FILE_TOOLS.has(event.toolName)) return undefined
20
+ const input = event.input as { path?: unknown; file_path?: unknown } | undefined
21
+ const target = typeof input?.path === 'string' ? input.path : input?.file_path
22
+ return typeof target === 'string' && target.length > 0 ? target : undefined
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.57",
3
+ "version": "1.0.58",
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",