@inixiative/foundry-core 0.4.0 → 0.5.1

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.
@@ -0,0 +1,904 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
4
+ import {
5
+ fenceSegments,
6
+ INSTRUCTION_FILE_LIMIT,
7
+ type InstructionFile,
8
+ readInstructionFile,
9
+ secretInstructionPath,
10
+ withinAny,
11
+ withinPath,
12
+ } from './markdown-docs';
13
+
14
+ /**
15
+ * A registered Markdown hierarchy: a ladder or tree of instruction nodes (computer, org, project,
16
+ * each shared or personal), composed for one working location into one ordered list of files, with
17
+ * duplicates removed. Harness-agnostic: which files a node reads, its tier and its position are data.
18
+ * Tiers are opaque labels here; the caller ranks them and decides who may see each.
19
+ *
20
+ * The same ladder composes every slot (e.g. instructions, domain knowledge) for every role (the main
21
+ * thread, each middleware role): a location names the role and slot it composes for, nodes and
22
+ * sources say which roles and slots they serve, and paths take `{role}` and `{slot}`.
23
+ */
24
+
25
+ /** When a node or source applies. Every listed key must hold; within a key, any value. */
26
+ export interface HierarchyMatch {
27
+ /** The working directory is inside one of these. */
28
+ readonly paths?: readonly string[];
29
+ /** The thread's project id is one of these. */
30
+ readonly projects?: readonly string[];
31
+ /** The thread's owner (e.g. a Kingdom owner key) is one of these. */
32
+ readonly owners?: readonly string[];
33
+ /** The harness the composed prompt is for. */
34
+ readonly harness?: readonly string[];
35
+ /** The role composed for (e.g. "main", a Warden's agent id). */
36
+ readonly roles?: readonly string[];
37
+ /** The slot composed (e.g. "instructions", "domainKnowledge"). */
38
+ readonly slots?: readonly string[];
39
+ }
40
+
41
+ /** Which directories of the root-to-cwd walk a walk source visits. */
42
+ export interface HierarchySpan {
43
+ /** Only directories inside one of these. */
44
+ readonly within?: readonly string[];
45
+ /** Only directories outside all of these. */
46
+ readonly outside?: readonly string[];
47
+ /** Only these exact directories. */
48
+ readonly only?: readonly string[];
49
+ /** Never these exact directories. */
50
+ readonly except?: readonly string[];
51
+ }
52
+
53
+ /** A file, a glob, or the first existing of several paths. */
54
+ export interface HierarchyFileSource {
55
+ /** Names the source, so a harness that loads it natively can leave it out (`harnessLoads`). */
56
+ readonly id?: string;
57
+ readonly when?: HierarchyMatch;
58
+ /**
59
+ * Absolute, `~/`, `{var}`-templated or (inside a walk, or with an anchor) relative. A glob expands
60
+ * in path order; a list loads the first that exists.
61
+ */
62
+ readonly path: string | readonly string[];
63
+ /** Rule files: frontmatter is stripped, and a file whose frontmatter names `paths` is skipped. */
64
+ readonly rules?: boolean;
65
+ /** Read as is: no comment stripping and no `@path` imports (e.g. an auto-memory index). */
66
+ readonly raw?: boolean;
67
+ readonly maxLines?: number;
68
+ readonly maxBytes?: number;
69
+ }
70
+
71
+ /** The same file sources, looked up in each directory of the walk from the filesystem root to the cwd. */
72
+ export interface HierarchyWalkSource {
73
+ readonly id?: string;
74
+ readonly when?: HierarchyMatch;
75
+ readonly walk: readonly HierarchyFileSource[];
76
+ readonly span?: HierarchySpan;
77
+ }
78
+
79
+ export type HierarchySource = HierarchyFileSource | HierarchyWalkSource;
80
+
81
+ export interface HierarchyNode {
82
+ readonly id: string;
83
+ /** computer, org, project, or any other level name. */
84
+ readonly scope: string;
85
+ /** Personal to the user rather than shared with a team or org. */
86
+ readonly personal: boolean;
87
+ /** The node's corpus tier, opaque to the composer. */
88
+ readonly tier: string;
89
+ readonly label?: string;
90
+ /** A child applies only where its parent applies, and orders after it. */
91
+ readonly parent?: string;
92
+ readonly when?: HierarchyMatch;
93
+ /**
94
+ * The directory the node is about, which places its files in the walk order. Default: the
95
+ * `when.paths` entry the cwd is inside. None: the node sits before every directory (computer level).
96
+ */
97
+ readonly anchor?: string;
98
+ readonly sources: readonly HierarchySource[];
99
+ /** Where its files may `@import` from: anywhere, or inside these roots (`{dir}`: the importing file's directory). */
100
+ readonly imports?: 'anywhere' | readonly string[];
101
+ /** Paths that are this node's wherever they are reached: an import of one keeps the narrower tier. */
102
+ readonly owns?: {
103
+ readonly names?: readonly string[];
104
+ readonly within?: readonly string[];
105
+ readonly dirs?: readonly string[];
106
+ };
107
+ }
108
+
109
+ /** Where a prompt is composed for. */
110
+ export interface HierarchyLocation {
111
+ readonly cwd: string;
112
+ readonly home: string;
113
+ /** Values for `{name}` in paths. A path naming an absent variable is skipped. */
114
+ readonly vars: Readonly<Record<string, string | undefined>>;
115
+ readonly projectId?: string;
116
+ readonly owner?: string;
117
+ readonly harness?: string;
118
+ /** Source ids the harness loads natively; those sources are left out. */
119
+ readonly harnessLoads?: readonly string[];
120
+ /** The role composed for; also `{role}` in paths. */
121
+ readonly role?: string;
122
+ /** The slot composed; also `{slot}` in paths. */
123
+ readonly slot?: string;
124
+ /**
125
+ * With a `slot`: which nodes' unslotted sources (no `slots` on the source, its walk or its node)
126
+ * compose into it. "all" (the default) or these node ids. Slotted sources follow `slots` only.
127
+ */
128
+ readonly unslotted?: 'all' | readonly string[];
129
+ /**
130
+ * Instruction homes (a CLAUDE_CONFIG_DIR, a CODEX_HOME) judged only below themselves by the
131
+ * secret check, so one under `~/.config` still loads.
132
+ */
133
+ readonly trusted?: readonly string[];
134
+ }
135
+
136
+ export interface HierarchyFile extends InstructionFile {
137
+ /** The node the file belongs to (an import may belong to a narrower node than its importer). */
138
+ readonly node: string;
139
+ readonly tier: string;
140
+ }
141
+
142
+ export type HierarchyDuplicateKind = 'path' | 'content' | 'section';
143
+
144
+ /** One thing left out as a duplicate, and the copy that was kept. */
145
+ export interface HierarchyDuplicate {
146
+ readonly kind: HierarchyDuplicateKind;
147
+ readonly path: string;
148
+ readonly node: string;
149
+ /** The section's heading, for a section duplicate. */
150
+ readonly heading?: string;
151
+ readonly chars: number;
152
+ readonly keptPath: string;
153
+ readonly keptNode: string;
154
+ }
155
+
156
+ /** A file the walk or a configured path reached but refused to load. */
157
+ export interface HierarchyRefusal {
158
+ readonly path: string;
159
+ readonly node: string;
160
+ /** Where its symlinks lead. */
161
+ readonly real: string;
162
+ readonly reason: 'secret' | 'outside-boundary';
163
+ }
164
+
165
+ export interface HierarchyStack {
166
+ readonly files: HierarchyFile[];
167
+ /** Files reached a second time (the same real path); the first reach is kept. */
168
+ readonly duplicates: HierarchyDuplicate[];
169
+ /** Files refused: a secret where they are or where they lead, or a real path outside the node's boundary. */
170
+ readonly refused: HierarchyRefusal[];
171
+ /**
172
+ * Every path consulted, found or not (candidate files, glob directories, imports). When none of
173
+ * them changed (`hierarchyFingerprint`), discovery would return the same stack.
174
+ */
175
+ readonly probes: readonly string[];
176
+ }
177
+
178
+ export interface HierarchyDiscoverOptions {
179
+ /** Lower is narrower (more private). An import never widens past its own location's tier. */
180
+ tierRank: (tier: string) => number;
181
+ /** Import hops; Claude Code follows four. */
182
+ maxDepth?: number;
183
+ }
184
+
185
+ const VAR = /\{([a-zA-Z][a-zA-Z0-9]*)\}/g;
186
+ const GLOB = /[*?[]/;
187
+
188
+ /** `~/` and `{var}` expanded; undefined when a variable is absent. */
189
+ export function expandHierarchyPath(
190
+ template: string,
191
+ vars: Readonly<Record<string, string | undefined>>,
192
+ home: string,
193
+ ): string | undefined {
194
+ let missing = false;
195
+ const out = template.replace(VAR, (_, name: string) => {
196
+ const value = vars[name];
197
+ if (value === undefined) missing = true;
198
+ return value ?? '';
199
+ });
200
+ if (missing) return undefined;
201
+ if (out === '~') return home;
202
+ return out.startsWith('~/') ? join(home, out.slice(2)) : out;
203
+ }
204
+
205
+ const isFile = (path: string) => {
206
+ try {
207
+ return statSync(path).isFile();
208
+ } catch {
209
+ return false;
210
+ }
211
+ };
212
+
213
+ function matches(when: HierarchyMatch | undefined, location: HierarchyLocation): boolean {
214
+ if (!when) return true;
215
+ if (when.harness && !(location.harness && when.harness.includes(location.harness))) return false;
216
+ if (when.projects && !(location.projectId && when.projects.includes(location.projectId)))
217
+ return false;
218
+ if (when.owners && !(location.owner && when.owners.includes(location.owner))) return false;
219
+ if (when.roles && !(location.role && when.roles.includes(location.role))) return false;
220
+ if (when.slots && !(location.slot && when.slots.includes(location.slot))) return false;
221
+ if (when.paths && !matchedPath(when.paths, location)) return false;
222
+ return true;
223
+ }
224
+
225
+ /** Location variables plus `{role}` and `{slot}`. */
226
+ const locationVars = (location: HierarchyLocation) => ({
227
+ ...location.vars,
228
+ ...(location.role !== undefined ? { role: location.role } : {}),
229
+ ...(location.slot !== undefined ? { slot: location.slot } : {}),
230
+ });
231
+
232
+ /** Whether a source no `when` along its chain assigns to a slot composes into the location's slot. */
233
+ function unslottedApplies(node: HierarchyNode, location: HierarchyLocation): boolean {
234
+ if (location.slot === undefined) return true;
235
+ const unslotted = location.unslotted ?? 'all';
236
+ return unslotted === 'all' || unslotted.includes(node.id);
237
+ }
238
+
239
+ function matchedPath(paths: readonly string[], location: HierarchyLocation): string | undefined {
240
+ const cwd = resolve(location.cwd);
241
+ const vars = locationVars(location);
242
+ return paths
243
+ .map((path) => expandHierarchyPath(path, vars, location.home))
244
+ .filter((path): path is string => !!path)
245
+ .map((path) => resolve(path))
246
+ .find((path) => withinAny([path])(cwd));
247
+ }
248
+
249
+ /** A selected node, its place in the ladder and the directory it is about. */
250
+ export interface SelectedHierarchyNode {
251
+ readonly node: HierarchyNode;
252
+ /** Ladder order: parents before children, siblings in registration order. */
253
+ readonly rank: number;
254
+ readonly anchor?: string;
255
+ }
256
+
257
+ /**
258
+ * The nodes that apply at `location`, in ladder order (a depth-first walk of the tree, roots and
259
+ * children in registration order). A node applies when its `when` holds and its parent applies.
260
+ */
261
+ export function selectHierarchyNodes(
262
+ nodes: readonly HierarchyNode[],
263
+ location: HierarchyLocation,
264
+ ): SelectedHierarchyNode[] {
265
+ const ids = new Set(nodes.map((node) => node.id));
266
+ const children = new Map<string | undefined, HierarchyNode[]>();
267
+ for (const node of nodes) {
268
+ const parent = node.parent && ids.has(node.parent) ? node.parent : undefined;
269
+ children.set(parent, [...(children.get(parent) ?? []), node]);
270
+ }
271
+ const selected: SelectedHierarchyNode[] = [];
272
+ let rank = 0;
273
+ const visit = (node: HierarchyNode, inherited: string | undefined) => {
274
+ const position = rank++;
275
+ if (!matches(node.when, location)) return;
276
+ // A node with no anchor of its own sits with its parent (org personal beside its org).
277
+ const own =
278
+ node.anchor !== undefined
279
+ ? expandHierarchyPath(node.anchor, locationVars(location), location.home)
280
+ : node.when?.paths
281
+ ? matchedPath(node.when.paths, location)
282
+ : undefined;
283
+ // A declared anchor that cannot resolve (an absent variable) means the node has nowhere to be.
284
+ if (node.anchor !== undefined && !own) return;
285
+ const anchor = own ? resolve(own) : inherited;
286
+ selected.push({ node, rank: position, ...(anchor ? { anchor } : {}) });
287
+ for (const child of children.get(node.id) ?? []) visit(child, anchor);
288
+ };
289
+ for (const root of children.get(undefined) ?? []) visit(root, undefined);
290
+ return selected.sort((a, b) => a.rank - b.rank);
291
+ }
292
+
293
+ /** Path depth: the walk order of a directory. A node with no anchor sits before them all. */
294
+ const depth = (path: string | undefined) =>
295
+ path === undefined ? -1 : resolve(path).split(sep).filter(Boolean).length;
296
+
297
+ interface Unit {
298
+ readonly position: number;
299
+ readonly rank: number;
300
+ readonly seq: number;
301
+ readonly selected: SelectedHierarchyNode;
302
+ readonly source: HierarchyFileSource;
303
+ readonly path: string;
304
+ }
305
+
306
+ /** Files under `base` matching `pattern`, in path order. Glob characters in `base` are literal. */
307
+ async function expandGlob(
308
+ base: string,
309
+ pattern: string,
310
+ probe: (path: string) => void,
311
+ ): Promise<string[]> {
312
+ const found: string[] = [];
313
+ probe(base);
314
+ try {
315
+ if (!statSync(base).isDirectory()) return [];
316
+ for await (const rel of new Bun.Glob(pattern).scan({ cwd: base, followSymlinks: true }))
317
+ found.push(join(base, rel));
318
+ } catch {
319
+ return [];
320
+ }
321
+ // A file added or removed changes its directory: every directory the pattern can reach is
322
+ // consulted, matched or not yet (a new rules/sub/new.md), up to a bound.
323
+ if (pattern.includes('/') || pattern.includes('**')) {
324
+ let dirs = 0;
325
+ try {
326
+ for await (const rel of new Bun.Glob('**').scan({
327
+ cwd: base,
328
+ onlyFiles: false,
329
+ followSymlinks: true,
330
+ })) {
331
+ const path = join(base, rel);
332
+ if (!isDirectory(path)) continue;
333
+ probe(path);
334
+ if (++dirs >= GLOB_PROBE_DIRECTORIES) break;
335
+ }
336
+ } catch {
337
+ /* unreadable: the base directory's own change still shows */
338
+ }
339
+ }
340
+ return found.sort();
341
+ }
342
+
343
+ const GLOB_PROBE_DIRECTORIES = 256;
344
+ const isDirectory = (path: string) => {
345
+ try {
346
+ return statSync(path).isDirectory();
347
+ } catch {
348
+ return false;
349
+ }
350
+ };
351
+
352
+ /**
353
+ * The state of `probes` (each path's identity, size and modification time, or its absence). Equal
354
+ * fingerprints mean no consulted file or directory changed since. Cheap: one stat per path.
355
+ */
356
+ export function hierarchyFingerprint(probes: readonly string[]): string {
357
+ return probes
358
+ .map((path) => {
359
+ try {
360
+ const info = statSync(path);
361
+ return `${path}\0${info.ino}:${info.size}:${info.mtimeMs}`;
362
+ } catch {
363
+ return `${path}\0-`;
364
+ }
365
+ })
366
+ .join('\n');
367
+ }
368
+
369
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
370
+
371
+ /** A rule loads at launch unless its frontmatter scopes it to `paths`. Unreadable: skipped. */
372
+ function launchRule(path: string): boolean {
373
+ try {
374
+ const frontmatter = FRONTMATTER.exec(readFileSync(path, 'utf8'))?.[1];
375
+ return !frontmatter || !/^paths\s*:/m.test(frontmatter);
376
+ } catch {
377
+ return false;
378
+ }
379
+ }
380
+
381
+ /** The directories from just below the filesystem root down to `cwd`. */
382
+ function walkChain(cwd: string): string[] {
383
+ const dirs: string[] = [];
384
+ for (let dir = resolve(cwd); dirname(dir) !== dir; dir = dirname(dir)) dirs.unshift(dir);
385
+ return dirs;
386
+ }
387
+
388
+ function inSpan(
389
+ dir: string,
390
+ span: HierarchySpan | undefined,
391
+ expand: (paths: readonly string[] | undefined) => string[] | undefined,
392
+ ): boolean {
393
+ if (!span) return true;
394
+ const within = expand(span.within);
395
+ const outside = expand(span.outside);
396
+ const only = expand(span.only);
397
+ const except = expand(span.except);
398
+ // An exclusion that cannot resolve (an absent variable) fails closed: nothing is in the span.
399
+ if (
400
+ (outside && outside.length !== span.outside?.length) ||
401
+ (except && except.length !== span.except?.length)
402
+ )
403
+ return false;
404
+ if (within && !within.some((root) => withinPath(dir, root))) return false;
405
+ if (outside?.some((root) => withinPath(dir, root))) return false;
406
+ if (only && !only.includes(dir)) return false;
407
+ if (except?.includes(dir)) return false;
408
+ return true;
409
+ }
410
+
411
+ /**
412
+ * Every file the selected nodes read at `location`, in composition order: computer-level nodes
413
+ * first, then each directory from the filesystem root down to the cwd; within one place, shared
414
+ * nodes before personal ones, each in ladder order (parents before children); within a node, its
415
+ * sources in order.
416
+ * Each file is followed by its `@path` imports; a file reached again is recorded, not loaded.
417
+ */
418
+ export async function discoverHierarchy(
419
+ nodes: readonly HierarchyNode[],
420
+ location: HierarchyLocation,
421
+ options: HierarchyDiscoverOptions,
422
+ ): Promise<HierarchyStack> {
423
+ const selected = selectHierarchyNodes(nodes, location);
424
+ const chain = walkChain(location.cwd);
425
+ const loadedNatively = new Set(location.harnessLoads ?? []);
426
+ const units: Unit[] = [];
427
+ let seq = 0;
428
+ const probes = new Set<string>();
429
+ const probe = (path: string) => {
430
+ probes.add(path);
431
+ };
432
+ // A trusted root is trusted as written and where its symlinks lead (~/.claude -> ~/.config/claude).
433
+ const trusted = [
434
+ ...new Set(
435
+ (location.trusted ?? []).flatMap((root) => {
436
+ try {
437
+ return [root, realpathSync(root)];
438
+ } catch {
439
+ return [root];
440
+ }
441
+ }),
442
+ ),
443
+ ];
444
+ const secret = (path: string) => secretInstructionPath(path, trusted);
445
+ const refused: HierarchyRefusal[] = [];
446
+
447
+ const realOf = (path: string) => {
448
+ try {
449
+ return realpathSync(path);
450
+ } catch {
451
+ return path;
452
+ }
453
+ };
454
+ const varsFor = (entry: SelectedHierarchyNode, dir?: string) => ({
455
+ ...locationVars(location),
456
+ ...(entry.anchor ? { anchor: entry.anchor } : {}),
457
+ ...(dir ? { dir } : {}),
458
+ });
459
+ const expandAll = (
460
+ paths: readonly string[] | undefined,
461
+ vars: Readonly<Record<string, string | undefined>>,
462
+ ) =>
463
+ paths
464
+ ?.map((path) => expandHierarchyPath(path, vars, location.home))
465
+ .filter((path): path is string => !!path)
466
+ .map((path) => resolve(path));
467
+
468
+ const plan = async (
469
+ entry: SelectedHierarchyNode,
470
+ source: HierarchyFileSource,
471
+ position: number,
472
+ base: string | undefined,
473
+ vars: Readonly<Record<string, string | undefined>>,
474
+ slotted: boolean,
475
+ ) => {
476
+ if (!matches(source.when, location) || (source.id && loadedNatively.has(source.id))) return;
477
+ if (!slotted && !source.when?.slots && !unslottedApplies(entry.node, location)) return;
478
+ const place = (path: string) => (isAbsolute(path) ? path : base ? join(base, path) : undefined);
479
+ const literal = (template: string) => {
480
+ const expanded = expandHierarchyPath(template, vars, location.home);
481
+ return expanded === undefined ? undefined : place(expanded);
482
+ };
483
+ let paths: string[];
484
+ if (typeof source.path !== 'string') {
485
+ const first = source.path
486
+ .map(literal)
487
+ .filter((path): path is string => !!path)
488
+ .find((path) => {
489
+ probe(path);
490
+ return isFile(path);
491
+ });
492
+ paths = first ? [first] : [];
493
+ } else {
494
+ // Glob characters count only where the template has them, never in a directory a variable names.
495
+ const segments = source.path.split('/');
496
+ const globAt = segments.findIndex((segment) => GLOB.test(segment));
497
+ if (globAt < 0) {
498
+ const path = literal(source.path);
499
+ if (path) probe(path);
500
+ paths = path ? [path] : [];
501
+ } else {
502
+ const root = segments.slice(0, globAt).join('/');
503
+ const dir =
504
+ root === '' && source.path.startsWith('/') ? '/' : root ? literal(root) : place('.');
505
+ paths = dir ? await expandGlob(resolve(dir), segments.slice(globAt).join('/'), probe) : [];
506
+ }
507
+ }
508
+ // A configured path is held to the import rule too, on where it resolves: no dotfiles, keys or credentials.
509
+ paths = paths.filter((path) => {
510
+ if (!isFile(path) || (!secret(path) && !secret(realOf(path)))) return true;
511
+ refused.push({ path, node: entry.node.id, real: realOf(path), reason: 'secret' });
512
+ return false;
513
+ });
514
+ if (source.rules) paths = paths.filter(launchRule);
515
+ for (const path of paths)
516
+ units.push({ position, rank: entry.rank, seq: seq++, selected: entry, source, path });
517
+ };
518
+
519
+ for (const entry of selected) {
520
+ const nodeSlotted = !!entry.node.when?.slots;
521
+ for (const source of entry.node.sources) {
522
+ if ('walk' in source) {
523
+ if (!matches(source.when, location) || (source.id && loadedNatively.has(source.id)))
524
+ continue;
525
+ const spanVars = varsFor(entry);
526
+ for (const dir of chain) {
527
+ if (!inSpan(dir, source.span, (paths) => expandAll(paths, spanVars))) continue;
528
+ for (const file of source.walk)
529
+ await plan(
530
+ entry,
531
+ file,
532
+ depth(dir),
533
+ dir,
534
+ varsFor(entry, dir),
535
+ nodeSlotted || !!source.when?.slots,
536
+ );
537
+ }
538
+ } else {
539
+ await plan(entry, source, depth(entry.anchor), entry.anchor, varsFor(entry), nodeSlotted);
540
+ }
541
+ }
542
+ }
543
+ const personal = (unit: Unit) => (unit.selected.node.personal ? 1 : 0);
544
+ units.sort(
545
+ (a, b) =>
546
+ a.position - b.position || personal(a) - personal(b) || a.rank - b.rank || a.seq - b.seq,
547
+ );
548
+
549
+ // An import keeps the narrower of its importer's tier and the tier of any node that owns its path.
550
+ const owners = selected.map((entry) => {
551
+ const vars = varsFor(entry);
552
+ return {
553
+ node: entry.node,
554
+ names: entry.node.owns?.names,
555
+ within: expandAll(entry.node.owns?.within, vars),
556
+ dirs: expandAll(entry.node.owns?.dirs, vars),
557
+ };
558
+ });
559
+ /** The narrowest node owning the path as spelled or where its symlinks lead. */
560
+ const owner = (spelled: string): HierarchyNode | undefined => {
561
+ let narrowest: HierarchyNode | undefined;
562
+ const paths = [...new Set([spelled, realOf(spelled)])];
563
+ for (const own of owners) {
564
+ const hit = paths.some(
565
+ (path) =>
566
+ own.names?.includes(basename(path)) ||
567
+ (own.within?.length && withinAny(own.within)(path)) ||
568
+ own.dirs?.includes(dirname(path)),
569
+ );
570
+ if (hit && (!narrowest || options.tierRank(own.node.tier) < options.tierRank(narrowest.tier)))
571
+ narrowest = own.node;
572
+ }
573
+ return narrowest;
574
+ };
575
+
576
+ /** The node a file reached through `via` belongs to: the narrower of `via` and any node owning its path. */
577
+ const tierOf = (path: string, via: HierarchyNode) => {
578
+ const own = owner(path);
579
+ return own && options.tierRank(own.tier) < options.tierRank(via.tier) ? own : via;
580
+ };
581
+ const seen = new Set<string>();
582
+ /** Each loaded file by real path: where it was kept and its index in `files`. */
583
+ const kept = new Map<string, { path: string; node: string; index: number }>();
584
+ /**
585
+ * A file reached again loads once, where first reached. A later reach that would give it a wider
586
+ * tier (a shared file also imported by a personal one) widens it, so a personal first reach never
587
+ * hides a shared file from a team.
588
+ */
589
+ const reachedAgain = (path: string, real: string, via: HierarchyNode) => {
590
+ const prior = kept.get(real);
591
+ if (!prior) return;
592
+ duplicates.push({
593
+ kind: 'path',
594
+ path,
595
+ node: via.id,
596
+ chars: 0,
597
+ keptPath: prior.path,
598
+ keptNode: prior.node,
599
+ });
600
+ const wider = tierOf(path, via);
601
+ const file = files[prior.index]!;
602
+ if (options.tierRank(wider.tier) > options.tierRank(file.tier)) {
603
+ files[prior.index] = { ...file, node: wider.id, tier: wider.tier };
604
+ kept.set(real, { ...prior, node: wider.id });
605
+ }
606
+ };
607
+ const files: HierarchyFile[] = [];
608
+ const duplicates: HierarchyDuplicate[] = [];
609
+ for (const unit of units) {
610
+ const { node } = unit.selected;
611
+ probe(unit.path);
612
+ if (unit.source.raw) {
613
+ if (!isFile(unit.path)) continue;
614
+ const real = realOf(unit.path);
615
+ if (kept.has(real)) {
616
+ reachedAgain(unit.path, real, node);
617
+ continue;
618
+ }
619
+ if (statSync(real).size > INSTRUCTION_FILE_LIMIT) continue;
620
+ let content = readFileSync(unit.path, 'utf8');
621
+ if (unit.source.maxLines !== undefined)
622
+ content = content.split('\n').slice(0, unit.source.maxLines).join('\n');
623
+ if (unit.source.maxBytes !== undefined)
624
+ content = Buffer.from(content).subarray(0, unit.source.maxBytes).toString('utf8');
625
+ const final = tierOf(unit.path, node);
626
+ seen.add(real);
627
+ kept.set(real, { path: unit.path, node: final.id, index: files.length });
628
+ files.push({ path: unit.path, content, depth: 0, node: final.id, tier: final.tier });
629
+ continue;
630
+ }
631
+ const allowed =
632
+ node.imports === undefined || node.imports === 'anywhere'
633
+ ? undefined
634
+ : withinAny(
635
+ node.imports
636
+ .map((root) =>
637
+ expandHierarchyPath(
638
+ root,
639
+ { ...varsFor(unit.selected), dir: dirname(unit.path) },
640
+ location.home,
641
+ ),
642
+ )
643
+ .filter((root): root is string => !!root),
644
+ );
645
+ // A shared node's file is held to its import boundary on where it resolves, like an import: a
646
+ // committed CLAUDE.md symlinked out of the repository never loads as the repository's. A
647
+ // personal node's file (CLAUDE.local.md pointing at private notes) reaches no shared audience.
648
+ const real = realOf(unit.path);
649
+ if (!node.personal && allowed && isFile(unit.path) && !allowed(real)) {
650
+ refused.push({ path: unit.path, node: node.id, real, reason: 'outside-boundary' });
651
+ continue;
652
+ }
653
+ const read = await readInstructionFile(unit.path, {
654
+ home: location.home,
655
+ seen,
656
+ trusted,
657
+ onProbe: probe,
658
+ ...(options.maxDepth !== undefined ? { maxDepth: options.maxDepth } : {}),
659
+ ...(allowed ? { allows: allowed } : {}),
660
+ onDuplicate: ({ path, real }) => reachedAgain(path, real, node),
661
+ });
662
+ for (const file of read) {
663
+ let content = file.content;
664
+ if (file.depth === 0 && unit.source.rules) content = content.replace(FRONTMATTER, '');
665
+ // Every file keeps the narrower tier of its node and any node owning its path, found or imported.
666
+ const final = tierOf(file.path, node);
667
+ kept.set(realOf(file.path), { path: file.path, node: final.id, index: files.length });
668
+ files.push({ ...file, content, node: final.id, tier: final.tier });
669
+ }
670
+ }
671
+ return { files, duplicates, refused, probes: [...probes] };
672
+ }
673
+
674
+ const digest = (text: string) => createHash('sha256').update(text).digest('hex');
675
+
676
+ /**
677
+ * Blank lines, trailing whitespace, line endings and runs of spaces inside a line do not make two
678
+ * texts different; indentation does (nested lists, YAML, code).
679
+ */
680
+ const normalizeBody = (text: string) =>
681
+ text
682
+ .split(/\r?\n|\r/)
683
+ .map((line) => {
684
+ const indent = /^[ \t]*/.exec(line)![0];
685
+ return indent + line.slice(indent.length).replace(/\s+/g, ' ').trimEnd();
686
+ })
687
+ .filter((line) => line.trim())
688
+ .join('\n');
689
+
690
+ const normalizeHeading = (heading: string) =>
691
+ heading
692
+ .replace(/[ \t]+#+[ \t]*$/, '')
693
+ .replace(/\s+/g, ' ')
694
+ .trim()
695
+ .toLowerCase();
696
+
697
+ const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/;
698
+
699
+ interface Section {
700
+ readonly heading: string;
701
+ readonly level: number;
702
+ /** Line range [start, end) including the heading and every subsection. */
703
+ readonly start: number;
704
+ readonly end: number;
705
+ /** Same heading path, heading and body (subsections included). Absent for a section with no body. */
706
+ readonly key?: string;
707
+ }
708
+
709
+ /**
710
+ * ATX sections outside fenced code and frontmatter; each spans its subsections. A section's key
711
+ * covers its enclosing headings too, document title included, so `Testing > Rules` and
712
+ * `Deploy > Rules` never match.
713
+ */
714
+ export function markdownSections(text: string): Section[] {
715
+ const all = text.split('\n');
716
+ const front = FRONTMATTER.exec(text)?.[0];
717
+ const skip = front ? front.split('\n').length - (front.endsWith('\n') ? 1 : 0) : 0;
718
+ const headings: Array<{ line: number; level: number; heading: string }> = [];
719
+ let line = 0;
720
+ for (const segment of fenceSegments(text)) {
721
+ const lines = segment.text.split('\n');
722
+ if (!segment.code)
723
+ lines.forEach((value, i) => {
724
+ const match = HEADING.exec(value);
725
+ if (match && line + i >= skip)
726
+ headings.push({ line: line + i, level: match[1]!.length, heading: match[2] ?? '' });
727
+ });
728
+ line += lines.length;
729
+ }
730
+ return headings.map((heading, i) => {
731
+ const next = headings.slice(i + 1).find((other) => other.level <= heading.level);
732
+ const end = next ? next.line : all.length;
733
+ const body = normalizeBody(all.slice(heading.line + 1, end).join('\n'));
734
+ const path: string[] = [];
735
+ let level = heading.level;
736
+ for (let j = i - 1; j >= 0; j--)
737
+ if (headings[j]!.level < level) {
738
+ path.unshift(normalizeHeading(headings[j]!.heading));
739
+ level = headings[j]!.level;
740
+ }
741
+ return {
742
+ heading: heading.heading.replace(/[ \t]+#+[ \t]*$/, '').trim(),
743
+ level: heading.level,
744
+ start: heading.line,
745
+ end,
746
+ ...(body
747
+ ? {
748
+ key: digest(
749
+ `${path.join('\n')}\n${heading.level}\n${normalizeHeading(heading.heading)}\n${body}`,
750
+ ),
751
+ }
752
+ : {}),
753
+ };
754
+ });
755
+ }
756
+
757
+ /** `lines` without the dropped ranges; a blank line left doubled at a cut is removed, nothing else changes. */
758
+ function cut(lines: readonly string[], dropped: readonly Section[]): string {
759
+ const out: string[] = [];
760
+ let atCut = false;
761
+ lines.forEach((line, i) => {
762
+ if (dropped.some((section) => i >= section.start && i < section.end)) {
763
+ atCut = true;
764
+ return;
765
+ }
766
+ if (atCut && !line.trim() && (out.length === 0 || !out.at(-1)!.trim())) return;
767
+ atCut = atCut && !line.trim();
768
+ out.push(line);
769
+ });
770
+ return out.join('\n');
771
+ }
772
+
773
+ export interface HierarchyDedupeOptions {
774
+ /** Two files with the same text: keep the more specific. Default true. */
775
+ readonly content?: boolean;
776
+ /** The same heading and body in two files: keep the more specific file's copy. Default true. */
777
+ readonly sections?: boolean;
778
+ }
779
+
780
+ /**
781
+ * Removes repeated content from an ordered stack (general first, specific last): identical files,
782
+ * then identical sections (normalized heading and body, subsections included). The most specific
783
+ * copy is kept; each removal names it. Filter by audience before deduping, so a kept copy the
784
+ * audience cannot see never hides one it can.
785
+ */
786
+ export function dedupeHierarchy<F extends HierarchyFile>(
787
+ files: readonly F[],
788
+ options: HierarchyDedupeOptions = {},
789
+ ): { files: F[]; duplicates: HierarchyDuplicate[] } {
790
+ const duplicates: HierarchyDuplicate[] = [];
791
+ let out = [...files];
792
+ if (options.content !== false) {
793
+ const later = new Map<string, F>();
794
+ const keep: F[] = [];
795
+ for (const file of [...out].reverse()) {
796
+ const text = normalizeBody(file.content);
797
+ if (!text) {
798
+ keep.unshift(file);
799
+ continue;
800
+ }
801
+ const key = digest(text);
802
+ const prior = later.get(key);
803
+ if (prior) {
804
+ duplicates.push({
805
+ kind: 'content',
806
+ path: file.path,
807
+ node: file.node,
808
+ chars: file.content.length,
809
+ keptPath: prior.path,
810
+ keptNode: prior.node,
811
+ });
812
+ continue;
813
+ }
814
+ later.set(key, file);
815
+ keep.unshift(file);
816
+ }
817
+ out = keep;
818
+ }
819
+ if (options.sections !== false) {
820
+ const later = new Map<string, F>();
821
+ const keep: F[] = [];
822
+ for (const file of [...out].reverse()) {
823
+ const sections = markdownSections(file.content);
824
+ const dropped: Section[] = [];
825
+ for (const section of sections) {
826
+ if (dropped.some((outer) => section.start >= outer.start && section.end <= outer.end))
827
+ continue;
828
+ const prior = section.key ? later.get(section.key) : undefined;
829
+ if (!prior) continue;
830
+ dropped.push(section);
831
+ const lines = file.content.split('\n').slice(section.start, section.end).join('\n');
832
+ duplicates.push({
833
+ kind: 'section',
834
+ path: file.path,
835
+ node: file.node,
836
+ heading: section.heading,
837
+ chars: lines.length,
838
+ keptPath: prior.path,
839
+ keptNode: prior.node,
840
+ });
841
+ }
842
+ for (const section of sections) {
843
+ if (!section.key || later.has(section.key)) continue;
844
+ if (dropped.some((outer) => section.start >= outer.start && section.end <= outer.end))
845
+ continue;
846
+ later.set(section.key, file);
847
+ }
848
+ if (!dropped.length) {
849
+ keep.unshift(file);
850
+ continue;
851
+ }
852
+ const content = cut(file.content.split('\n'), dropped);
853
+ if (content.trim()) keep.unshift({ ...file, content });
854
+ }
855
+ out = keep;
856
+ }
857
+ return { files: out, duplicates: duplicates.reverse() };
858
+ }
859
+
860
+ /** A duplicate removed while deduping slots together: which slot lost the copy and which kept it. */
861
+ export interface HierarchySlotDuplicate extends HierarchyDuplicate {
862
+ readonly slot: number;
863
+ readonly keptSlot: number;
864
+ }
865
+
866
+ /**
867
+ * Dedupe one role's slots together (each an ordered stack, general first), in slot order: a file or
868
+ * section repeated in a later slot is kept there and cut from the earlier one, so a role never reads
869
+ * the same text twice. Filter by audience first, as for `dedupeHierarchy`.
870
+ */
871
+ export function dedupeHierarchySlots<F extends HierarchyFile>(
872
+ slots: readonly (readonly F[])[],
873
+ options: HierarchyDedupeOptions = {},
874
+ ): { slots: F[][]; duplicates: HierarchySlotDuplicate[] } {
875
+ // The slot rides on the node id through dedupe (a cut file is a spread copy) and is split off after.
876
+ const SEP = '\u0000';
877
+ const tagged = slots.flatMap((files, slot) =>
878
+ files.map((file) => ({ ...file, node: `${slot}${SEP}${file.node}` })),
879
+ );
880
+ const split = (node: string) => {
881
+ const at = node.indexOf(SEP);
882
+ return { slot: Number(node.slice(0, at)), node: node.slice(at + 1) };
883
+ };
884
+ const { files, duplicates } = dedupeHierarchy(tagged, options);
885
+ const out: F[][] = slots.map(() => []);
886
+ for (const file of files) {
887
+ const { slot, node } = split(file.node);
888
+ out[slot]!.push({ ...file, node } as F);
889
+ }
890
+ return {
891
+ slots: out,
892
+ duplicates: duplicates.map((duplicate) => {
893
+ const lost = split(duplicate.node);
894
+ const kept = split(duplicate.keptNode);
895
+ return {
896
+ ...duplicate,
897
+ node: lost.node,
898
+ keptNode: kept.node,
899
+ slot: lost.slot,
900
+ keptSlot: kept.slot,
901
+ };
902
+ }),
903
+ };
904
+ }