pi-code 1.0.57 → 1.0.59
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.
- package/extensions/claude-rules.ts +3 -4
- package/extensions/context-imports.ts +335 -33
- package/extensions/init.ts +5 -3
- package/extensions/internal/context-files.ts +14 -0
- package/extensions/internal/external-imports.ts +74 -0
- package/extensions/internal/project-approval.ts +14 -0
- package/extensions/internal/project-root.ts +18 -0
- package/extensions/internal/tool-target.ts +23 -0
- package/package.json +1 -1
|
@@ -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))
|
|
@@ -67,18 +67,20 @@ import { createHash } from 'node:crypto'
|
|
|
67
67
|
import * as fs from 'node:fs'
|
|
68
68
|
import * as os from 'node:os'
|
|
69
69
|
import * as path from 'node:path'
|
|
70
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
71
|
-
|
|
70
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
72
71
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
72
|
+
import { AGENTS_FILE_NAMES } from './internal/context-files.js'
|
|
73
|
+
import { externalImportDecision, externalImportKey, rememberExternalImportDecision } from './internal/external-imports.js'
|
|
73
74
|
import { type InstructionLoadEvent, memoryTypeForPath, publishInstructionLoad } from './internal/instruction-events.js'
|
|
74
75
|
import { managedSettingsPath, readManagedSettings } from './internal/managed-settings.js'
|
|
75
|
-
import { sliceBytes } from './internal/output-guard.js'
|
|
76
|
+
import { capForContext, sliceBytes } from './internal/output-guard.js'
|
|
76
77
|
import { globToRegExpSource } from './internal/path-rules.js'
|
|
77
|
-
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
78
|
+
import { isGatedFileApproved, isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
78
79
|
import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
|
|
79
80
|
import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
|
|
80
81
|
import { statToken } from './internal/stat-token.js'
|
|
81
82
|
import { type Fence, fenceMarker, stepFence, stripBlockComments } from './internal/strip-comments.js'
|
|
83
|
+
import { fileToolTarget } from './internal/tool-target.js'
|
|
82
84
|
|
|
83
85
|
/** Claude documents "a maximum depth of four hops" for recursive imports. */
|
|
84
86
|
const MAX_IMPORT_DEPTH = 4
|
|
@@ -106,8 +108,13 @@ export function expandHome(target: string, home: string): string {
|
|
|
106
108
|
return target
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
/** Stands for "no boundary" in an allowed-roots list: the grant a project gets when
|
|
112
|
+
* its external imports are approved, where a file may import from anywhere as Claude's
|
|
113
|
+
* dialog allows. A real path never equals it, and realRoots never produces it. */
|
|
114
|
+
const ANY_ROOT = '*'
|
|
115
|
+
|
|
109
116
|
function isUnder(target: string, roots: string[]): boolean {
|
|
110
|
-
return roots.some((root) => target === root || target.startsWith(root + path.sep))
|
|
117
|
+
return roots.some((root) => root === ANY_ROOT || target === root || target.startsWith(root + path.sep))
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
/** Realpath the roots that exist; used both to seed and to bound the import search. */
|
|
@@ -138,9 +145,12 @@ export interface ImportBudget {
|
|
|
138
145
|
files: number
|
|
139
146
|
bytes: number
|
|
140
147
|
dropped: number
|
|
148
|
+
/** Existing files an @import named that resolve outside the importer's allowed
|
|
149
|
+
* roots. Collected so the refusal can be reported rather than left silent. */
|
|
150
|
+
refused: Set<string>
|
|
141
151
|
}
|
|
142
152
|
|
|
143
|
-
export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0 })
|
|
153
|
+
export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0, refused: new Set() })
|
|
144
154
|
|
|
145
155
|
/** The `@path` targets of a context file, in document order. Claude Code evaluates
|
|
146
156
|
* imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
|
|
@@ -162,16 +172,28 @@ function importTargets(content: string): string[] {
|
|
|
162
172
|
}
|
|
163
173
|
|
|
164
174
|
/** 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
|
|
175
|
+
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
176
|
const resolved = path.resolve(fromDir, expandHome(target, home))
|
|
177
|
+
// Always the path the importing file named, never where a symlink pointed: the
|
|
178
|
+
// notice would otherwise hand a repo the real name of whatever the link reaches,
|
|
179
|
+
// which is the disclosure the refusal exists to prevent. An excluded file is not
|
|
180
|
+
// named either, since exclusion removes it from every other surface too.
|
|
181
|
+
const refuse = (): null => {
|
|
182
|
+
if (isExcluded?.(resolved) !== true) refused.add(resolved)
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
167
185
|
let real: string
|
|
168
186
|
try {
|
|
169
187
|
real = fs.realpathSync(resolved)
|
|
170
188
|
} catch {
|
|
171
|
-
|
|
189
|
+
// Not on disk. Still refused when it points outside, so that whether a path is
|
|
190
|
+
// reported never depends on whether it exists: a notice that named only the
|
|
191
|
+
// existing ones would enumerate the filesystem for any repo-controlled file
|
|
192
|
+
// willing to write one @line per guess.
|
|
193
|
+
return isUnder(resolved, allowedRoots) ? null : refuse()
|
|
172
194
|
}
|
|
173
195
|
if (seen.has(real)) return null
|
|
174
|
-
if (!isUnder(real, allowedRoots)) return
|
|
196
|
+
if (!isUnder(real, allowedRoots)) return refuse()
|
|
175
197
|
// Checked before the read so an excluded file contributes nothing: no body, no
|
|
176
198
|
// transitive imports, no budget spend, no announce. A post-collection filter
|
|
177
199
|
// would drop the file itself but keep its children.
|
|
@@ -182,6 +204,9 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
|
|
|
182
204
|
// Only a consumed file dedupes: marking a blocked or unreadable target seen
|
|
183
205
|
// would let one reader's failure suppress the import for a later, allowed one.
|
|
184
206
|
seen.add(real)
|
|
207
|
+
// A file another importer already refused is in context after all; the notice
|
|
208
|
+
// must not claim otherwise.
|
|
209
|
+
refused.delete(resolved)
|
|
185
210
|
return { real, body }
|
|
186
211
|
} catch {
|
|
187
212
|
return null
|
|
@@ -218,7 +243,7 @@ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth:
|
|
|
218
243
|
scan.budget.dropped += 1
|
|
219
244
|
continue
|
|
220
245
|
}
|
|
221
|
-
const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded)
|
|
246
|
+
const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded, scan.budget.refused)
|
|
222
247
|
if (!file) continue
|
|
223
248
|
scan.budget.files -= 1
|
|
224
249
|
// The budget is bytes: a string slice counts UTF-16 units and lets CJK text through
|
|
@@ -252,11 +277,16 @@ export function collectImports(content: string, fromDir: string, home: string, a
|
|
|
252
277
|
* project's transcripts, so granting those roots to a cloned repo's `CLAUDE.md`
|
|
253
278
|
* would let it read them into the system prompt.
|
|
254
279
|
*/
|
|
255
|
-
export function rootsForImporter(importer: string, home: string, cwd: string): string[] {
|
|
280
|
+
export function rootsForImporter(importer: string, home: string, cwd: string, externalApproved = false): string[] {
|
|
256
281
|
const userRoots = realRoots([claudeConfigDir(home), path.join(home, '.pi')])
|
|
257
282
|
const [real] = realRoots([importer])
|
|
258
283
|
const fromUserConfig = real !== undefined && isUnder(real, userRoots)
|
|
259
284
|
if (fromUserConfig) return realRoots([cwd, ...userRoots])
|
|
285
|
+
// The project was asked about its external imports and allowed them, so a project
|
|
286
|
+
// file may reach outside, as Claude's dialog grants. The widening is deliberately
|
|
287
|
+
// only for project files: a user-scope file's roots are its own config, and an
|
|
288
|
+
// approval given to a repository must not extend them.
|
|
289
|
+
if (externalApproved) return [ANY_ROOT]
|
|
260
290
|
// A non-config file is bounded at the repository root: that covers an ancestor
|
|
261
291
|
// context file (a repo-root CLAUDE.md or CLAUDE.local.md in a subdirectory
|
|
262
292
|
// session, where cwd alone silently dropped its relative imports) without
|
|
@@ -479,24 +509,205 @@ function additionalDirExtras(addDirs: string[], seenSet: Set<string>, excluded:
|
|
|
479
509
|
return extras
|
|
480
510
|
}
|
|
481
511
|
|
|
512
|
+
/** The parts of one launch-time expansion that every file shares: where it runs, what
|
|
513
|
+
* has already been read, the budget they all draw on, and whether this project's
|
|
514
|
+
* external imports were approved. */
|
|
515
|
+
interface ExpansionContext {
|
|
516
|
+
home: string
|
|
517
|
+
cwd: string
|
|
518
|
+
seen: Set<string>
|
|
519
|
+
excluded: (absPath: string) => boolean
|
|
520
|
+
budget: ImportBudget
|
|
521
|
+
externalApproved: boolean
|
|
522
|
+
}
|
|
523
|
+
|
|
482
524
|
/** Resolve every context and additional-dir file's @imports through the one shared
|
|
483
525
|
* budget, each with roots scoped to the importing file so a project file never
|
|
484
|
-
* reaches user config. */
|
|
485
|
-
function expandImports(contextFiles: Array<{ path: string; content: string }>, extras: Array<{ path: string; content: string; dir: string }>,
|
|
526
|
+
* reaches user config unless the project's external imports were approved. */
|
|
527
|
+
function expandImports(contextFiles: Array<{ path: string; content: string }>, extras: Array<{ path: string; content: string; dir: string }>, run: ExpansionContext): ImportedFile[] {
|
|
486
528
|
const imported: ImportedFile[] = []
|
|
529
|
+
const options = { budget: run.budget, isExcluded: run.excluded }
|
|
487
530
|
for (const file of contextFiles) {
|
|
488
|
-
const allowedRoots = rootsForImporter(file.path, home, cwd)
|
|
489
|
-
imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots,
|
|
531
|
+
const allowedRoots = rootsForImporter(file.path, run.home, run.cwd, run.externalApproved)
|
|
532
|
+
imported.push(...collectImports(file.content, path.dirname(file.path), run.home, allowedRoots, run.seen, { ...options, importer: file.path }))
|
|
490
533
|
}
|
|
491
534
|
for (const extra of extras) {
|
|
492
535
|
// The additional dir itself is an allowed root, so its files' relative imports
|
|
493
536
|
// resolve even from .claude/rules two levels down.
|
|
494
|
-
const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, home, cwd)]
|
|
495
|
-
imported.push(...collectImports(extra.content, path.dirname(extra.path), home, allowedRoots,
|
|
537
|
+
const allowedRoots = [...realRoots([extra.dir]), ...rootsForImporter(extra.path, run.home, run.cwd)]
|
|
538
|
+
imported.push(...collectImports(extra.content, path.dirname(extra.path), run.home, allowedRoots, run.seen, { ...options, importer: extra.path }))
|
|
496
539
|
}
|
|
497
540
|
return imported
|
|
498
541
|
}
|
|
499
542
|
|
|
543
|
+
/** The external-import dialog's title. Exported so a test can tell it apart from the
|
|
544
|
+
* project-trust dialog by identity rather than by matching a prefix that a retitle
|
|
545
|
+
* would silently break. */
|
|
546
|
+
export const EXTERNAL_IMPORT_PROMPT_TITLE = 'Load imports from outside this project?'
|
|
547
|
+
|
|
548
|
+
/** Ask about the imports the expansion just refused for leaving the project.
|
|
549
|
+
*
|
|
550
|
+
* The list is the refusals the enforcing path produced, not a second enumeration of
|
|
551
|
+
* what it might refuse: same files, same depth, same resolution, same exclusions. That
|
|
552
|
+
* is the only way the dialog can promise it names everything the approval lets in.
|
|
553
|
+
*/
|
|
554
|
+
async function askExternalImports(ctx: ExtensionContext, root: string, refused: ReadonlySet<string>): Promise<boolean> {
|
|
555
|
+
const listed = [...refused]
|
|
556
|
+
.sort((a, b) => a.localeCompare(b, 'en'))
|
|
557
|
+
.map((file) => ` ${file}`)
|
|
558
|
+
.join('\n')
|
|
559
|
+
const body = `${root}\n\nIts context files import these files from outside the project:\n\n${listed}\n\nThey will be read into every session's context. Only allow this for repositories you trust.`
|
|
560
|
+
return await ctx.ui.confirm(EXTERNAL_IMPORT_PROMPT_TITLE, body)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Every context file that could load on demand for a touched directory, shallowest
|
|
564
|
+
* first, so the deepest instructions are read last as they are at launch. */
|
|
565
|
+
function* nestedCandidates(touchedDir: string, realCwd: string): Generator<{ file: string; dir: string; name: string }> {
|
|
566
|
+
for (const dir of nestedContextDirs(touchedDir, realCwd)) {
|
|
567
|
+
for (const name of NESTED_CONTEXT_NAMES) yield { file: path.join(dir, name), dir, name }
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Everything the on-demand load needs from the session and the tool call, grouped so
|
|
572
|
+
* the per-file worker stays a three-argument function. */
|
|
573
|
+
interface NestedLoadContext {
|
|
574
|
+
home: string
|
|
575
|
+
cwd: string
|
|
576
|
+
realCwd: string
|
|
577
|
+
projectRoot: string
|
|
578
|
+
excludeGlobs: string[]
|
|
579
|
+
/** The file whose access triggered the load, for trigger_file_path. */
|
|
580
|
+
touched: string
|
|
581
|
+
/** Paths already in the system prompt, so a nested @import does not repeat one. */
|
|
582
|
+
launchLoaded: string[]
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** One nested context file's block and the instruction loads it should announce, or
|
|
586
|
+
* nothing to attach: absent, excluded, empty, or reaching outside the project. `read`
|
|
587
|
+
* reports whether the file was there at all, so a file that exists is only ever
|
|
588
|
+
* attached once while a missing one can still appear later in the session. */
|
|
589
|
+
function nestedContextBlock(file: string, dir: string, load: NestedLoadContext): { read: boolean; text?: string; events?: InstructionLoadEvent[] } {
|
|
590
|
+
if (isExcludedPath(file, load.excludeGlobs, load.home)) return { read: false }
|
|
591
|
+
// The file itself may be a link out of the project, whatever its directory is.
|
|
592
|
+
const [real] = realRoots([file])
|
|
593
|
+
if (real === undefined || !isUnder(real, [load.realCwd])) return { read: false }
|
|
594
|
+
const content = readContextFile(real)
|
|
595
|
+
if (content === undefined) return { read: false }
|
|
596
|
+
const body = stripBlockComments(content).trim()
|
|
597
|
+
if (body.length === 0) return { read: true }
|
|
598
|
+
// Its own @imports resolve at project roots, on a budget of their own: this load is
|
|
599
|
+
// outside the launch-time expansion the shared budget covers. The launch-time paths
|
|
600
|
+
// seed the seen set, so a nested file importing the root CLAUDE.md does not pay for
|
|
601
|
+
// a body already in the system prompt.
|
|
602
|
+
const seen = new Set([...load.launchLoaded, real])
|
|
603
|
+
const budget = createImportBudget()
|
|
604
|
+
const imports = collectImports(content, dir, load.home, rootsForImporter(real, load.home, load.cwd), seen, {
|
|
605
|
+
importer: real,
|
|
606
|
+
isExcluded: (absPath) => isExcludedPath(absPath, load.excludeGlobs, load.home),
|
|
607
|
+
budget,
|
|
608
|
+
})
|
|
609
|
+
return {
|
|
610
|
+
read: true,
|
|
611
|
+
// The refusal notice rides along, so an import this file names and does not get is
|
|
612
|
+
// as visible here as it is at launch.
|
|
613
|
+
text: [instructionsBlock(file, body), ...imports.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`)].join('\n\n') + refusedImportsAddition(budget.refused),
|
|
614
|
+
events: [
|
|
615
|
+
{ file_path: file, memory_type: memoryTypeForPath(file, load.home, load.projectRoot), load_reason: 'nested_traversal', trigger_file_path: load.touched },
|
|
616
|
+
...imports.map((entry) => ({
|
|
617
|
+
file_path: entry.path,
|
|
618
|
+
memory_type: memoryTypeForPath(entry.path, load.home, load.projectRoot),
|
|
619
|
+
load_reason: 'include',
|
|
620
|
+
parent_file_path: file,
|
|
621
|
+
})),
|
|
622
|
+
],
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** The context files Claude loads on demand rather than at launch, in the order it
|
|
627
|
+
* reads them within a directory. */
|
|
628
|
+
const NESTED_CONTEXT_NAMES = ['CLAUDE.md', 'CLAUDE.local.md'] as const
|
|
629
|
+
|
|
630
|
+
/** The directories between a touched file and cwd, nearest cwd first.
|
|
631
|
+
*
|
|
632
|
+
* Claude loads CLAUDE.md from cwd and every directory above it at launch, and the
|
|
633
|
+
* ones below "are included when Claude reads files in those directories"
|
|
634
|
+
* (memory.md). Only the strictly-below range belongs here; cwd's own file is
|
|
635
|
+
* already in the prompt. A file outside cwd contributes nothing.
|
|
636
|
+
*/
|
|
637
|
+
function nestedContextDirs(from: string, cwd: string): string[] {
|
|
638
|
+
const dirs: string[] = []
|
|
639
|
+
let current = from
|
|
640
|
+
while (current !== cwd && current !== path.dirname(current)) {
|
|
641
|
+
dirs.unshift(current)
|
|
642
|
+
current = path.dirname(current)
|
|
643
|
+
}
|
|
644
|
+
// The walk reached the filesystem root without meeting cwd, so the file is outside
|
|
645
|
+
// it: nothing below cwd to load. A file in cwd itself ends the loop with no dirs.
|
|
646
|
+
return current === cwd ? dirs : []
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** The blocks and instruction loads for one touched directory. `loaded` is the
|
|
650
|
+
* session's set of already-attached files and is updated in place, so a file that
|
|
651
|
+
* exists is attached once and a missing one can still appear later. */
|
|
652
|
+
function nestedContextAttachments(touchedDir: string, load: NestedLoadContext, loaded: Set<string>, localsApproved: boolean): { bodies: string[]; events: InstructionLoadEvent[] } {
|
|
653
|
+
const bodies: string[] = []
|
|
654
|
+
const events: InstructionLoadEvent[] = []
|
|
655
|
+
for (const { file, dir, name } of nestedCandidates(touchedDir, load.realCwd)) {
|
|
656
|
+
if (loaded.has(file)) continue
|
|
657
|
+
// A CLAUDE.local.md needs a decision, never the "nothing here to gate" shortcut:
|
|
658
|
+
// the approval walk only looks at or above cwd, so this is the one door it cannot
|
|
659
|
+
// see (see isGatedFileApproved).
|
|
660
|
+
if (name === 'CLAUDE.local.md' && !localsApproved) continue
|
|
661
|
+
const block = nestedContextBlock(file, dir, load)
|
|
662
|
+
if (block.read) loaded.add(file)
|
|
663
|
+
if (block.text === undefined) continue
|
|
664
|
+
bodies.push(block.text)
|
|
665
|
+
events.push(...(block.events ?? []))
|
|
666
|
+
}
|
|
667
|
+
return { bodies, events }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** The CLAUDE.md files pi passed over.
|
|
671
|
+
*
|
|
672
|
+
* Claude Code reads CLAUDE.md and never AGENTS.md, and its documented recipe for a
|
|
673
|
+
* repository that already has an AGENTS.md is a CLAUDE.md that imports it and adds
|
|
674
|
+
* Claude-specific instructions below (memory.md, "AGENTS.md"). pi loads one context
|
|
675
|
+
* file per directory and prefers AGENTS.md, so on exactly that layout the
|
|
676
|
+
* Claude-specific half never reaches the prompt. Each one found here is loaded like
|
|
677
|
+
* any other project file: exclude-checked, comment-stripped, imports expanded.
|
|
678
|
+
*
|
|
679
|
+
* The `@AGENTS.md` the recipe opens with costs nothing: pi's own file paths already
|
|
680
|
+
* seed the import seen-set, so the body it names is not injected a second time. */
|
|
681
|
+
function siblingClaudeMdFiles(native: Array<{ path: string; content: string }>): Array<{ path: string; content: string }> {
|
|
682
|
+
const loaded = new Set(realRoots(native.map((file) => file.path)))
|
|
683
|
+
const found: Array<{ path: string; content: string }> = []
|
|
684
|
+
for (const file of native) {
|
|
685
|
+
if (!AGENTS_FILE_NAMES.has(path.basename(file.path))) continue
|
|
686
|
+
// CLAUDE.md only: pi also answers to CLAUDE.MD, but Claude Code reads the one
|
|
687
|
+
// spelling, and on a case-insensitive filesystem looking for both finds the same
|
|
688
|
+
// file twice under two names.
|
|
689
|
+
const candidate = path.join(path.dirname(file.path), 'CLAUDE.md')
|
|
690
|
+
const [real] = realRoots([candidate])
|
|
691
|
+
if (real !== undefined && loaded.has(real)) continue
|
|
692
|
+
const content = readContextFile(candidate)
|
|
693
|
+
if (content === undefined) continue
|
|
694
|
+
found.push({ path: candidate, content })
|
|
695
|
+
}
|
|
696
|
+
return found
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** The CLAUDE.md files pi passed over, appended as project_instructions blocks in the
|
|
700
|
+
* ./CLAUDE.md slot, ahead of the ./.claude/CLAUDE.md alternate and the locals. */
|
|
701
|
+
function siblingClaudeMdAddition(siblings: Array<{ path: string; content: string }>, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
|
|
702
|
+
let addition = ''
|
|
703
|
+
for (const sibling of siblings) {
|
|
704
|
+
if (sibling.content.trim().length === 0) continue
|
|
705
|
+
announce({ file_path: sibling.path, memory_type: memoryTypeForPath(sibling.path, home, projectRoot), load_reason: 'session_start' })
|
|
706
|
+
addition += `\n\n${instructionsBlock(sibling.path, sibling.content.trim())}`
|
|
707
|
+
}
|
|
708
|
+
return addition
|
|
709
|
+
}
|
|
710
|
+
|
|
500
711
|
/** The project-scope ./.claude/CLAUDE.md appended as a project_instructions block,
|
|
501
712
|
* announced as it is added (only when non-empty, matching what reaches the prompt).
|
|
502
713
|
* Claude reads project instructions from ./CLAUDE.md OR ./.claude/CLAUDE.md; pi loads
|
|
@@ -535,6 +746,19 @@ function additionalDirsAddition(extras: Array<{ path: string; content: string; d
|
|
|
535
746
|
/** The `## Imported context (@)` section for every resolved @import, with the
|
|
536
747
|
* budget-exhaustion notice, announcing each as an `include`. Empty when nothing
|
|
537
748
|
* was imported. */
|
|
749
|
+
/** The refusal notice: every existing file an @import named that its importer may not
|
|
750
|
+
* reach. Claude asks about these through an approval dialog and loads the ones you
|
|
751
|
+
* allow; pi-code refuses them, and this is what says so. Silence was the real defect:
|
|
752
|
+
* the importing file looks loaded and its instructions are simply not there. */
|
|
753
|
+
function refusedImportsAddition(refused: Set<string>): string {
|
|
754
|
+
if (refused.size === 0) return ''
|
|
755
|
+
const list = [...refused]
|
|
756
|
+
.sort((a, b) => a.localeCompare(b, 'en'))
|
|
757
|
+
.map((file) => `- ${file}`)
|
|
758
|
+
.join('\n')
|
|
759
|
+
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}`
|
|
760
|
+
}
|
|
761
|
+
|
|
538
762
|
function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
|
|
539
763
|
if (imported.length === 0) return ''
|
|
540
764
|
const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
|
|
@@ -584,6 +808,7 @@ function buildImportMemoKey(input: {
|
|
|
584
808
|
cwd: string
|
|
585
809
|
home: string
|
|
586
810
|
projectApproved: boolean
|
|
811
|
+
externalApproved: boolean
|
|
587
812
|
addDirsRaw: string
|
|
588
813
|
excludeGlobs: string[]
|
|
589
814
|
native: Array<{ path: string; content: string }>
|
|
@@ -594,7 +819,7 @@ function buildImportMemoKey(input: {
|
|
|
594
819
|
contextFiles: Array<{ path: string; content: string }>
|
|
595
820
|
}): string {
|
|
596
821
|
const keyHash = createHash('sha256')
|
|
597
|
-
keyHash.update(`${input.cwd}\0${input.home}\0${input.projectApproved}\0${input.addDirsRaw}\0${input.excludeGlobs.join(',')}\0`)
|
|
822
|
+
keyHash.update(`${input.cwd}\0${input.home}\0${input.projectApproved}\0${input.externalApproved}\0${input.addDirsRaw}\0${input.excludeGlobs.join(',')}\0`)
|
|
598
823
|
for (const file of [...input.native, ...input.localContexts]) keyHash.update(`${file.path}\0`)
|
|
599
824
|
if (input.userContext !== undefined) keyHash.update(`${input.userContext.path}\0`)
|
|
600
825
|
if (input.projectDotClaude !== undefined) keyHash.update(`${input.projectDotClaude.path}\0`)
|
|
@@ -613,9 +838,19 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
613
838
|
// natively but not this one; repo-controlled, so it is approval-gated like the
|
|
614
839
|
// locals and read once at session start.
|
|
615
840
|
let projectDotClaude: { path: string; content: string } | undefined
|
|
841
|
+
// Whether a file that is itself the thing to gate may be read. Decided at session
|
|
842
|
+
// start like the flag below, from the session context, which is where the trust
|
|
843
|
+
// capability lives; a tool_result context carries no approval state.
|
|
844
|
+
let gatedFilesApproved = false
|
|
616
845
|
// Whether project settings may contribute claudeMdExcludes; decided at session
|
|
617
846
|
// start with the silent check, so no prompt fires mid-flight.
|
|
618
847
|
let projectApproved = false
|
|
848
|
+
// Every context file the last turn put in the system prompt, so a nested file's
|
|
849
|
+
// @import cannot pay for a body that is already there.
|
|
850
|
+
let launchLoadedPaths: string[] = []
|
|
851
|
+
// Nested CLAUDE.md/CLAUDE.local.md files already attached this session, so a second
|
|
852
|
+
// read in the same subtree does not repeat them.
|
|
853
|
+
const nestedLoaded = new Set<string>()
|
|
619
854
|
// Instruction loads already announced on the shared bus, keyed reason:path.
|
|
620
855
|
// before_agent_start fires every turn, so without this a configured
|
|
621
856
|
// InstructionsLoaded hook would fire once per file per turn.
|
|
@@ -658,11 +893,8 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
658
893
|
// set) is rebuilt for next turn.
|
|
659
894
|
const resolveImports = (
|
|
660
895
|
memoKey: string,
|
|
661
|
-
native: Array<{ path: string; content: string }
|
|
662
|
-
|
|
663
|
-
home: string,
|
|
664
|
-
cwd: string,
|
|
665
|
-
excluded: (absPath: string) => boolean,
|
|
896
|
+
files: { native: Array<{ path: string; content: string }>; context: Array<{ path: string; content: string }>; siblings: Array<{ path: string; content: string }> },
|
|
897
|
+
run: { home: string; cwd: string; excluded: (absPath: string) => boolean; externalApproved: boolean },
|
|
666
898
|
): { extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] } => {
|
|
667
899
|
if (importMemo?.key === memoKey && memoIsFresh(importMemo)) {
|
|
668
900
|
const { extras, budget, imported } = importMemo
|
|
@@ -672,7 +904,7 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
672
904
|
// files are never re-imported and an excluded file cannot return as an import.
|
|
673
905
|
// The user, project-.claude and managed-file additions join the seed too, so a
|
|
674
906
|
// context file's @import cannot pull any of them in a second time.
|
|
675
|
-
const ownPaths = [...native, ...localContexts].map((file) => file.path)
|
|
907
|
+
const ownPaths = [...files.native, ...localContexts, ...files.siblings].map((file) => file.path)
|
|
676
908
|
if (userContext !== undefined) ownPaths.push(userContext.path)
|
|
677
909
|
if (projectDotClaude !== undefined) ownPaths.push(projectDotClaude.path)
|
|
678
910
|
ownPaths.push(managedClaudeMdPath())
|
|
@@ -681,14 +913,14 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
681
913
|
// Claude's --add-dir memory loading, env-gated. The files join the seen set
|
|
682
914
|
// before import expansion so an @import cannot pull one in twice, and they get
|
|
683
915
|
// the same exclude and comment-strip treatment as native context files.
|
|
684
|
-
const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), home, cwd) : []
|
|
685
|
-
const extras = additionalDirExtras(addDirs, seenSet, excluded, projectApproved)
|
|
916
|
+
const addDirs = additionalDirsClaudeMdEnabled() ? parseAdditionalDirs(pi.getFlag?.('add-dir'), run.home, run.cwd) : []
|
|
917
|
+
const extras = additionalDirExtras(addDirs, seenSet, run.excluded, projectApproved)
|
|
686
918
|
|
|
687
919
|
// One budget for the whole run, so N context files cannot each spend a full one.
|
|
688
920
|
// Exclusion applies inside the recursion: an excluded @import is skipped before
|
|
689
921
|
// it is read, so its transitive imports never load and it spends no budget.
|
|
690
922
|
const budget = createImportBudget()
|
|
691
|
-
const imported = expandImports(
|
|
923
|
+
const imported = expandImports(files.context, extras, { ...run, seen: seenSet, budget })
|
|
692
924
|
|
|
693
925
|
// Revalidation set: every file the expansion read, plus each add-dir itself
|
|
694
926
|
// (a directory's mtime moves when a memory file is added or removed there).
|
|
@@ -749,9 +981,30 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
749
981
|
}
|
|
750
982
|
// Read after the local-context flow so an approval it just recorded is honored.
|
|
751
983
|
projectApproved = isProjectApprovedSilently(ctx)
|
|
984
|
+
gatedFilesApproved = isGatedFileApproved(ctx)
|
|
752
985
|
})
|
|
753
986
|
|
|
754
|
-
|
|
987
|
+
/** The launch-time expansion, asking about what it refuses for leaving the project
|
|
988
|
+
* when this project has not been asked yet.
|
|
989
|
+
*
|
|
990
|
+
* Claude asks once per project and remembers the answer either way. The list is what
|
|
991
|
+
* this very expansion refused, so it names exactly the files the answer governs, and
|
|
992
|
+
* on approval the expansion is simply redone with the wider roots. */
|
|
993
|
+
const expandAskingAboutExternals = async (
|
|
994
|
+
cwd: string,
|
|
995
|
+
ctx: ExtensionContext | undefined,
|
|
996
|
+
expandWith: (externalApproved: boolean) => { extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] },
|
|
997
|
+
): Promise<{ extras: Array<{ path: string; content: string; dir: string }>; budget: ImportBudget; imported: ImportedFile[] }> => {
|
|
998
|
+
const key = externalImportKey(cwd)
|
|
999
|
+
const decided = externalImportDecision(key)
|
|
1000
|
+
const result = expandWith(decided === true)
|
|
1001
|
+
if (decided !== null || result.budget.refused.size === 0 || ctx?.hasUI !== true) return result
|
|
1002
|
+
const approved = await askExternalImports(ctx, key, result.budget.refused)
|
|
1003
|
+
rememberExternalImportDecision(key, approved)
|
|
1004
|
+
return approved ? expandWith(true) : result
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
pi.on('before_agent_start', async (event, ctx) => {
|
|
755
1008
|
const home = os.homedir()
|
|
756
1009
|
const cwd = event.systemPromptOptions?.cwd ?? process.cwd()
|
|
757
1010
|
const native: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
|
|
@@ -792,33 +1045,82 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
792
1045
|
|
|
793
1046
|
const keptLocals = localContexts.filter((local) => !excluded(local.path)).map((local) => ({ path: local.path, content: stripBlockComments(local.content) }))
|
|
794
1047
|
|
|
1048
|
+
// Repo-controlled text, so gated on the same approval as the locals and the
|
|
1049
|
+
// ./.claude/CLAUDE.md; read here rather than at session start because only the
|
|
1050
|
+
// turn's contextFiles say which file pi actually chose per directory.
|
|
1051
|
+
const keptSiblings = (projectApproved ? siblingClaudeMdFiles(native) : []).filter((sibling) => !excluded(sibling.path)).map((sibling) => ({ path: sibling.path, content: stripBlockComments(sibling.content) }))
|
|
1052
|
+
|
|
795
1053
|
// ./.claude/CLAUDE.md, deduped against pi's native context so that if pi ever
|
|
796
1054
|
// loads it too there is no double block, then exclude-checked and comment-stripped
|
|
797
1055
|
// like the rest. Its @imports resolve at project roots (rootsForImporter).
|
|
798
1056
|
const nativeReal = new Set(realRoots(native.map((file) => file.path)))
|
|
799
1057
|
const [dotReal] = projectDotClaude !== undefined ? realRoots([projectDotClaude.path]) : []
|
|
800
|
-
const
|
|
1058
|
+
const siblingReal = new Set(realRoots(keptSiblings.map((sibling) => sibling.path)))
|
|
1059
|
+
const keptProjectDotClaude = projectDotClaude !== undefined && !(dotReal !== undefined && (nativeReal.has(dotReal) || siblingReal.has(dotReal))) && !excluded(projectDotClaude.path) ? { path: projectDotClaude.path, content: stripBlockComments(projectDotClaude.content) } : undefined
|
|
801
1060
|
|
|
802
1061
|
// The user CLAUDE.md and ./.claude/CLAUDE.md join the import-expansion set so their
|
|
803
1062
|
// @imports resolve (each at roots scoped to it, via rootsForImporter); their own
|
|
804
1063
|
// bodies are placed separately, so expansion only surfaces what they import.
|
|
805
|
-
const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
|
|
1064
|
+
const contextFiles = [...rewrite.kept, ...(keptUser !== undefined ? [keptUser] : []), ...keptSiblings, ...(keptProjectDotClaude !== undefined ? [keptProjectDotClaude] : []), ...keptLocals]
|
|
806
1065
|
|
|
807
1066
|
// Everything the expansion depends on, hashed: a turn whose inputs match the memo
|
|
808
1067
|
// and whose recorded mtimes are unchanged reuses the previous expansion outright.
|
|
809
1068
|
const addDirsRaw = additionalDirsClaudeMdEnabled() ? String(pi.getFlag?.('add-dir') ?? '') : ''
|
|
810
|
-
const
|
|
1069
|
+
const expandWith = (externalApproved: boolean) => {
|
|
1070
|
+
const memoKey = buildImportMemoKey({ cwd, home, projectApproved, externalApproved, addDirsRaw, excludeGlobs, native, localContexts, userContext, projectDotClaude, managedFile, contextFiles })
|
|
1071
|
+
return resolveImports(memoKey, { native, context: contextFiles, siblings: keptSiblings }, { home, cwd, excluded, externalApproved })
|
|
1072
|
+
}
|
|
811
1073
|
|
|
812
|
-
|
|
1074
|
+
// Claude's external-import dialog. Asked from the refusals the expansion just
|
|
1075
|
+
// produced, so the files named are exactly the files the answer governs, and asked
|
|
1076
|
+
// here rather than at session start because only this event knows which context
|
|
1077
|
+
// files pi actually loaded. Once per project: the answer is remembered either way.
|
|
1078
|
+
const { extras, budget, imported } = await expandAskingAboutExternals(cwd, ctx, expandWith)
|
|
1079
|
+
launchLoadedPaths = realRoots([...contextFiles.map((file) => file.path), ...imported.map((entry) => entry.path)])
|
|
813
1080
|
|
|
814
1081
|
// Project memory precedes local memory, so the ./.claude/CLAUDE.md block leads the
|
|
815
1082
|
// additions, ahead of the CLAUDE.local.md bodies.
|
|
816
|
-
let addition =
|
|
1083
|
+
let addition = siblingClaudeMdAddition(keptSiblings, home, projectRoot, announce)
|
|
1084
|
+
addition += projectContextAddition(keptProjectDotClaude, home, projectRoot, announce)
|
|
817
1085
|
addition += localContextAddition(keptLocals, announce)
|
|
818
1086
|
addition += additionalDirsAddition(extras, announce)
|
|
819
1087
|
addition += importedAddition(imported, budget, home, projectRoot, announce)
|
|
1088
|
+
addition += refusedImportsAddition(budget.refused)
|
|
820
1089
|
if (!changed && addition.length === 0) return
|
|
821
1090
|
|
|
822
1091
|
return { systemPrompt: prompt + addition }
|
|
823
1092
|
})
|
|
1093
|
+
|
|
1094
|
+
// Claude's nested traversal: CLAUDE.md and CLAUDE.local.md below the working
|
|
1095
|
+
// directory are not loaded at launch but "are included when Claude reads files in
|
|
1096
|
+
// those subdirectories" (memory.md). pi's loader stops at cwd, so the whole
|
|
1097
|
+
// below-cwd range is missing; this attaches each one to the tool result that
|
|
1098
|
+
// touched its directory, which is the same seam claude-rules uses for a scoped
|
|
1099
|
+
// rule. Once per file per session, ordered shallowest first so the deepest
|
|
1100
|
+
// instructions are read last, matching the launch-time ordering.
|
|
1101
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
1102
|
+
const rel = fileToolTarget(event)
|
|
1103
|
+
if (rel === undefined) return
|
|
1104
|
+
// Repo-controlled text, gated like every other project file this extension adds.
|
|
1105
|
+
if (!projectApproved) return
|
|
1106
|
+
// Realpath both sides: the walk below is lexical, so a symlinked subdirectory
|
|
1107
|
+
// would otherwise carry it straight out of the project.
|
|
1108
|
+
const [realCwd = ctx.cwd] = realRoots([ctx.cwd])
|
|
1109
|
+
const touched = path.resolve(ctx.cwd, rel)
|
|
1110
|
+
// The directory, not the file: a write creates its target, and a read of a path
|
|
1111
|
+
// that has since moved should still contribute what its directory holds.
|
|
1112
|
+
const [touchedDir] = realRoots([path.dirname(touched)])
|
|
1113
|
+
if (touchedDir === undefined) return
|
|
1114
|
+
|
|
1115
|
+
const home = os.homedir()
|
|
1116
|
+
const projectRoot = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
1117
|
+
const excludeGlobs = readClaudeMdExcludes(claudeMdExcludeFiles(ctx.cwd, home, projectApproved), readManagedSettings())
|
|
1118
|
+
const load: NestedLoadContext = { home, cwd: ctx.cwd, realCwd, projectRoot, excludeGlobs, touched, launchLoaded: launchLoadedPaths }
|
|
1119
|
+
const { bodies, events } = nestedContextAttachments(touchedDir, load, nestedLoaded, gatedFilesApproved)
|
|
1120
|
+
for (const loaded of events) announce(loaded)
|
|
1121
|
+
if (bodies.length === 0) return
|
|
1122
|
+
// Capped like every other tool output: one read in a deep subtree must not be
|
|
1123
|
+
// able to spend the context window on memory files.
|
|
1124
|
+
return { content: [...event.content, { type: 'text' as const, text: capForContext(bodies.join('\n\n')) }] }
|
|
1125
|
+
})
|
|
824
1126
|
}
|
package/extensions/init.ts
CHANGED
|
@@ -16,13 +16,15 @@
|
|
|
16
16
|
|
|
17
17
|
import * as fs from 'node:fs'
|
|
18
18
|
import * as path from 'node:path'
|
|
19
|
+
|
|
20
|
+
import { CONTEXT_FILE_CANDIDATES } from './internal/context-files.js'
|
|
21
|
+
|
|
22
|
+
export { CONTEXT_FILE_CANDIDATES }
|
|
23
|
+
|
|
19
24
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
20
25
|
|
|
21
26
|
import { repoRoot } from './internal/project-root.js'
|
|
22
27
|
|
|
23
|
-
/** Context files pi recognizes, in its lookup order; the first hit wins. */
|
|
24
|
-
export const CONTEXT_FILE_CANDIDATES = ['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
|
|
25
|
-
|
|
26
28
|
function statOf(target: string): fs.Stats | undefined {
|
|
27
29
|
try {
|
|
28
30
|
return fs.statSync(target)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context-file names pi looks for, in its own lookup order.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors pi's resource loader: it takes the first of these that exists in a
|
|
5
|
+
* directory and ignores the rest, so a repository holding both AGENTS.md and
|
|
6
|
+
* CLAUDE.md loads only the first. Anything reasoning about what pi loaded, or about
|
|
7
|
+
* what it passed over, has to use the same list in the same order.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** pi's per-directory candidates, first hit wins. */
|
|
11
|
+
export const CONTEXT_FILE_CANDIDATES = ['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
|
|
12
|
+
|
|
13
|
+
/** The candidates pi prefers over CLAUDE.md in the same directory. */
|
|
14
|
+
export const AGENTS_FILE_NAMES: ReadonlySet<string> = new Set(['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD'])
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a project may load `@imports` that resolve outside it.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code asks once per project, listing the external files, and remembers the
|
|
5
|
+
* answer: "The first time Claude Code encounters external imports in a project, it
|
|
6
|
+
* shows an approval dialog listing the files. If you decline, the imports stay
|
|
7
|
+
* disabled and the dialog doesn't appear again."
|
|
8
|
+
*
|
|
9
|
+
* The answer lives beside pi's own trust store rather than in the repository, so a
|
|
10
|
+
* clone cannot ship its own approval. It is keyed on the checkout: the resolved git
|
|
11
|
+
* root, or the resolved working directory outside a repository.
|
|
12
|
+
*
|
|
13
|
+
* The key is the git root and not the project root because a repository must not be
|
|
14
|
+
* able to move its own key. The project root also stops at package.json, so declining
|
|
15
|
+
* at the top of a monorepo and starting the next session inside a package produced a
|
|
16
|
+
* different key and asked again, which is not a decision that was kept. A separate
|
|
17
|
+
* worktree of one repository is a separate checkout and is asked separately.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from 'node:fs'
|
|
21
|
+
import * as path from 'node:path'
|
|
22
|
+
|
|
23
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
24
|
+
|
|
25
|
+
import { atomicWriteFile } from './atomic-write.js'
|
|
26
|
+
import { gitRoot } from './project-root.js'
|
|
27
|
+
import { isRecord } from './values.js'
|
|
28
|
+
|
|
29
|
+
/** The key for a working directory: its checkout, resolved, so the same checkout
|
|
30
|
+
* reached through a symlink is the same project. */
|
|
31
|
+
export function externalImportKey(cwd: string): string {
|
|
32
|
+
const root = gitRoot(cwd) ?? cwd
|
|
33
|
+
try {
|
|
34
|
+
return fs.realpathSync(root)
|
|
35
|
+
} catch {
|
|
36
|
+
return root
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The store file. A seam: the tests point it at a temp directory. */
|
|
41
|
+
export function externalImportStorePath(agentDir: string = getAgentDir()): string {
|
|
42
|
+
return path.join(agentDir, 'pi-code-external-imports.json')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readStore(storePath: string): Record<string, unknown> {
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(storePath, 'utf-8'))
|
|
48
|
+
return isRecord(parsed) ? parsed : {}
|
|
49
|
+
} catch {
|
|
50
|
+
// No store yet, or one a hand-edit left unparseable: no decision recorded.
|
|
51
|
+
return {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The recorded answer for `root`, or null when the project has never been asked.
|
|
56
|
+
* Only a boolean counts: anything else in the file reads as unasked, so a corrupt
|
|
57
|
+
* entry re-asks rather than silently allowing or silently refusing forever. */
|
|
58
|
+
export function externalImportDecision(root: string, storePath: string = externalImportStorePath()): boolean | null {
|
|
59
|
+
const value = readStore(storePath)[root]
|
|
60
|
+
return typeof value === 'boolean' ? value : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Record the answer for `root`, keeping every other project's. */
|
|
64
|
+
export function rememberExternalImportDecision(root: string, allowed: boolean, storePath: string = externalImportStorePath()): void {
|
|
65
|
+
const store = readStore(storePath)
|
|
66
|
+
store[root] = allowed
|
|
67
|
+
try {
|
|
68
|
+
fs.mkdirSync(path.dirname(storePath), { recursive: true })
|
|
69
|
+
atomicWriteFile(storePath, `${JSON.stringify(store, null, 2)}\n`)
|
|
70
|
+
} catch {
|
|
71
|
+
// An unwritable agent directory costs the memory of the answer, not the session:
|
|
72
|
+
// the next start asks again, which is the safe direction.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -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
|
|
@@ -27,6 +27,24 @@ export function repoRoot(from: string): string | undefined {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** The git checkout at or above `from`, or undefined outside one.
|
|
31
|
+
*
|
|
32
|
+
* Narrower than repoRoot on purpose, and used where a key must be stable rather than
|
|
33
|
+
* merely near: repoRoot also stops at package.json, which every package of a monorepo
|
|
34
|
+
* ships, so a decision keyed on it changes the moment the session starts one directory
|
|
35
|
+
* deeper. `.git` cannot be committed into a repository, so it is not a marker the
|
|
36
|
+
* repository can add to move its own key.
|
|
37
|
+
*/
|
|
38
|
+
export function gitRoot(from: string): string | undefined {
|
|
39
|
+
let currentDir = from
|
|
40
|
+
while (true) {
|
|
41
|
+
if (fs.existsSync(path.join(currentDir, '.git'))) return currentDir
|
|
42
|
+
const parentDir = path.dirname(currentDir)
|
|
43
|
+
if (parentDir === currentDir) return undefined
|
|
44
|
+
currentDir = parentDir
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
30
48
|
function statOf(target: string): fs.Stats | null {
|
|
31
49
|
try {
|
|
32
50
|
return fs.statSync(target)
|
|
@@ -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.59",
|
|
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",
|