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
|
-
|
|
345
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
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",
|