@hraness/kb 0.17.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.
Files changed (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,1241 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import {
4
+ lstat,
5
+ open,
6
+ realpath,
7
+ } from "node:fs/promises";
8
+ import {
9
+ isAbsolute,
10
+ join,
11
+ posix,
12
+ relative,
13
+ resolve,
14
+ sep,
15
+ } from "node:path";
16
+
17
+ import type { Note } from "./graph.js";
18
+
19
+ export const agentContextType = "agent-context";
20
+ export const agentContextDirectory = "scopes";
21
+ export const agentContextSlugMaximumLength = 48;
22
+ export const agentContextHashLength = 12;
23
+
24
+ const drivePathPattern = /^[A-Za-z]:/u;
25
+ const globCharacterPattern = /[*?[\]{}]/u;
26
+ const canonicalContextIdPattern =
27
+ /^scopes\/([a-z0-9]+(?:-[a-z0-9]+)*)--([0-9a-f]{12})$/u;
28
+
29
+ function hasControlCharacter(value: string): boolean {
30
+ for (const character of value) {
31
+ const codePoint = character.codePointAt(0);
32
+ if (
33
+ codePoint !== undefined
34
+ && (
35
+ codePoint <= 0x1f
36
+ || (codePoint >= 0x7f && codePoint <= 0x9f)
37
+ )
38
+ ) {
39
+ return true;
40
+ }
41
+ }
42
+ return false;
43
+ }
44
+
45
+ export type RepositoryScopeErrorCode =
46
+ | "empty"
47
+ | "absolute"
48
+ | "traversal"
49
+ | "control-character"
50
+ | "glob";
51
+
52
+ /** A caller supplied a value that cannot name one exact repository directory. */
53
+ export class RepositoryScopeError extends Error {
54
+ readonly code: RepositoryScopeErrorCode;
55
+ readonly input: string;
56
+
57
+ constructor(code: RepositoryScopeErrorCode, input: string, message: string) {
58
+ super(message);
59
+ this.name = "RepositoryScopeError";
60
+ this.code = code;
61
+ this.input = input;
62
+ }
63
+ }
64
+
65
+ function normalizedRepositoryPath(
66
+ input: string,
67
+ unicodeForm: "NFC" | null,
68
+ ): string {
69
+ if (input === "" || input.trim() === "") {
70
+ throw new RepositoryScopeError(
71
+ "empty",
72
+ input,
73
+ "A repository scope must not be empty.",
74
+ );
75
+ }
76
+ if (hasControlCharacter(input)) {
77
+ throw new RepositoryScopeError(
78
+ "control-character",
79
+ input,
80
+ "A repository scope must not contain control characters.",
81
+ );
82
+ }
83
+ if (/^[\\/]/u.test(input) || drivePathPattern.test(input)) {
84
+ throw new RepositoryScopeError(
85
+ "absolute",
86
+ input,
87
+ "A repository scope must be relative, not absolute, UNC, or drive-qualified.",
88
+ );
89
+ }
90
+ if (globCharacterPattern.test(input)) {
91
+ throw new RepositoryScopeError(
92
+ "glob",
93
+ input,
94
+ "A repository scope must name one exact directory, not a glob.",
95
+ );
96
+ }
97
+
98
+ const separated = input.replaceAll("\\", "/");
99
+ if (separated.split("/").includes("..")) {
100
+ throw new RepositoryScopeError(
101
+ "traversal",
102
+ input,
103
+ "A repository scope must not contain parent traversal.",
104
+ );
105
+ }
106
+ const unicodeNormalized = unicodeForm === null
107
+ ? separated
108
+ : separated.normalize(unicodeForm);
109
+ const normalized = posix.normalize(unicodeNormalized).replace(/^\.\//u, "");
110
+ if (
111
+ normalized === ".."
112
+ || normalized.startsWith("../")
113
+ || posix.isAbsolute(normalized)
114
+ ) {
115
+ throw new RepositoryScopeError(
116
+ "traversal",
117
+ input,
118
+ "A repository scope must stay within the repository.",
119
+ );
120
+ }
121
+ return normalized === "" ? "." : normalized;
122
+ }
123
+
124
+ /**
125
+ * Normalize a POSIX or backslash-delimited repository directory to NFC.
126
+ *
127
+ * The repository root is always represented by `.`. Absolute paths, parent
128
+ * traversal, controls, globs, and empty inputs are rejected.
129
+ */
130
+ export function normalizeRepositoryScope(input: string): string {
131
+ return normalizedRepositoryPath(input, "NFC");
132
+ }
133
+
134
+ function readableScopeSlug(scope: string): string {
135
+ if (scope === ".") return "repository";
136
+ const readable = scope
137
+ .normalize("NFKD")
138
+ .replace(/\p{Mark}+/gu, "")
139
+ .replace(/[^\p{ASCII}]/gu, "-")
140
+ .toLocaleLowerCase("en-US")
141
+ .replace(/[^a-z0-9]+/gu, "-")
142
+ .replace(/^-+|-+$/gu, "")
143
+ .replace(/-+/gu, "-");
144
+ const fallback = readable === "" ? "scope" : readable;
145
+ return fallback
146
+ .slice(0, agentContextSlugMaximumLength)
147
+ .replace(/-+$/gu, "") || "scope";
148
+ }
149
+
150
+ function scopeHash(scope: string): string {
151
+ return createHash("sha256")
152
+ .update(scope, "utf8")
153
+ .digest("hex")
154
+ .slice(0, agentContextHashLength);
155
+ }
156
+
157
+ /** Return the canonical vault note ID for one repository scope. */
158
+ export function agentContextNoteId(scopeInput: string): string {
159
+ const scope = normalizeRepositoryScope(scopeInput);
160
+ return `${agentContextDirectory}/${readableScopeSlug(scope)}--${scopeHash(scope)}`;
161
+ }
162
+
163
+ /** Return the canonical vault-relative Markdown path for one repository scope. */
164
+ export function agentContextNotePath(scopeInput: string): string {
165
+ return `${agentContextNoteId(scopeInput)}.md`;
166
+ }
167
+
168
+ /** Return the repository-relative guide path owned by one repository scope. */
169
+ export function agentContextGuidePath(scopeInput: string): string {
170
+ const scope = normalizeRepositoryScope(scopeInput);
171
+ return scope === "." ? "AGENTS.md" : `${scope}/AGENTS.md`;
172
+ }
173
+
174
+ function isCanonicalContextId(value: string): boolean {
175
+ const match = canonicalContextIdPattern.exec(value);
176
+ const slug = match?.[1];
177
+ return slug !== undefined && slug.length <= agentContextSlugMaximumLength;
178
+ }
179
+
180
+ /** Format the exact reciprocal guide marker for a canonical context note ID. */
181
+ export function formatAgentContextMarker(noteId: string): string {
182
+ if (!isCanonicalContextId(noteId)) {
183
+ throw new TypeError("An agent-context marker requires a canonical context note ID.");
184
+ }
185
+ return `<!-- kb:context ${noteId} -->`;
186
+ }
187
+
188
+ /** Return the reciprocal guide marker for one repository scope. */
189
+ export function agentContextMarkerForScope(scopeInput: string): string {
190
+ return formatAgentContextMarker(agentContextNoteId(scopeInput));
191
+ }
192
+
193
+ export type AgentContextMarker = {
194
+ readonly noteId: string;
195
+ readonly line: number;
196
+ readonly source: string;
197
+ };
198
+
199
+ export type MalformedAgentContextMarker = {
200
+ readonly line: number;
201
+ readonly source: string;
202
+ readonly reason: "syntax" | "after-heading";
203
+ };
204
+
205
+ export type AgentContextMarkerParseResult = {
206
+ readonly kind: "missing" | "found" | "multiple" | "malformed";
207
+ readonly markers: readonly AgentContextMarker[];
208
+ readonly malformed: readonly MalformedAgentContextMarker[];
209
+ };
210
+
211
+ function fenceDelimiter(line: string): { character: "`" | "~"; length: number } | null {
212
+ const match = /^\s{0,3}(`{3,}|~{3,})/u.exec(line);
213
+ const delimiter = match?.[1];
214
+ if (delimiter === undefined) return null;
215
+ const character = delimiter[0];
216
+ return character === "`" || character === "~"
217
+ ? { character, length: delimiter.length }
218
+ : null;
219
+ }
220
+
221
+ /**
222
+ * Parse the reserved `kb:context` guide comment.
223
+ *
224
+ * Other HTML comments and marker examples inside fenced code are ignored.
225
+ * Marker-like comments with non-exact syntax or placement are reported.
226
+ */
227
+ export function parseAgentContextMarker(source: string): AgentContextMarkerParseResult {
228
+ const markers: AgentContextMarker[] = [];
229
+ const malformed: MalformedAgentContextMarker[] = [];
230
+ let firstHeadingLine: number | null = null;
231
+ let fence: { character: "`" | "~"; length: number } | null = null;
232
+
233
+ const lines = source.split(/\r?\n/u);
234
+ for (let index = 0; index < lines.length; index += 1) {
235
+ const line = lines[index] ?? "";
236
+ const delimiter = fenceDelimiter(line);
237
+ if (fence !== null) {
238
+ if (
239
+ delimiter?.character === fence.character
240
+ && delimiter.length >= fence.length
241
+ ) {
242
+ fence = null;
243
+ }
244
+ continue;
245
+ }
246
+ if (delimiter !== null) {
247
+ fence = delimiter;
248
+ continue;
249
+ }
250
+
251
+ const lineNumber = index + 1;
252
+ if (
253
+ firstHeadingLine === null
254
+ && /^\s{0,3}#{1,6}(?:\s+|$)/u.test(line)
255
+ ) {
256
+ firstHeadingLine = lineNumber;
257
+ }
258
+ if (!/<!--[^>]*\bkb:context\b/u.test(line)) continue;
259
+
260
+ const match = /^<!-- kb:context (scopes\/[a-z0-9]+(?:-[a-z0-9]+)*--[0-9a-f]{12}) -->$/u
261
+ .exec(line);
262
+ const noteId = match?.[1];
263
+ if (noteId === undefined || !isCanonicalContextId(noteId)) {
264
+ malformed.push({ line: lineNumber, source: line, reason: "syntax" });
265
+ continue;
266
+ }
267
+ if (firstHeadingLine !== null && lineNumber > firstHeadingLine) {
268
+ malformed.push({ line: lineNumber, source: line, reason: "after-heading" });
269
+ continue;
270
+ }
271
+ markers.push({ noteId, line: lineNumber, source: line });
272
+ }
273
+
274
+ const kind = malformed.length > 0
275
+ ? "malformed"
276
+ : markers.length === 0
277
+ ? "missing"
278
+ : markers.length === 1
279
+ ? "found"
280
+ : "multiple";
281
+ return { kind, markers, malformed };
282
+ }
283
+
284
+ export type AgentGuideSource = {
285
+ readonly path: string;
286
+ readonly source: string;
287
+ };
288
+
289
+ export type AgentContextHub = {
290
+ readonly note: Note;
291
+ readonly rawScope: string;
292
+ readonly scope: string;
293
+ readonly canonicalId: string;
294
+ readonly canonicalPath: string;
295
+ readonly guidePath: string;
296
+ readonly marker: string;
297
+ readonly canonical: boolean;
298
+ readonly reciprocal: boolean;
299
+ readonly valid: boolean;
300
+ };
301
+
302
+ export type AgentContextGuide = {
303
+ readonly path: string;
304
+ readonly scope: string;
305
+ readonly source: string;
306
+ readonly marker: AgentContextMarkerParseResult;
307
+ };
308
+
309
+ type Issue<K extends string, D extends object> = Readonly<{
310
+ kind: K;
311
+ message: string;
312
+ } & D>;
313
+
314
+ export type AgentContextIssue =
315
+ | Issue<"malformed-context-type", {
316
+ notePath: string;
317
+ actual: unknown;
318
+ }>
319
+ | Issue<"malformed-context-scope", {
320
+ notePath: string;
321
+ actual: unknown;
322
+ reason: string;
323
+ }>
324
+ | Issue<"context-note-outside-scopes", {
325
+ notePath: string;
326
+ }>
327
+ | Issue<"non-context-note-under-scopes", {
328
+ notePath: string;
329
+ }>
330
+ | Issue<"noncanonical-context-note", {
331
+ notePath: string;
332
+ noteId: string;
333
+ scope: string;
334
+ expectedPath: string;
335
+ expectedId: string;
336
+ }>
337
+ | Issue<"duplicate-context-scope", {
338
+ scope: string;
339
+ notePaths: readonly string[];
340
+ }>
341
+ | Issue<"nfc-context-scope-collision", {
342
+ scope: string;
343
+ rawScopes: readonly string[];
344
+ notePaths: readonly string[];
345
+ }>
346
+ | Issue<"case-fold-context-scope-collision", {
347
+ scopes: readonly string[];
348
+ notePaths: readonly string[];
349
+ }>
350
+ | Issue<"invalid-guide-path", {
351
+ guidePath: string;
352
+ reason: string;
353
+ }>
354
+ | Issue<"guide-marker-missing", {
355
+ guidePath: string;
356
+ scope: string;
357
+ }>
358
+ | Issue<"guide-marker-multiple", {
359
+ guidePath: string;
360
+ lines: readonly number[];
361
+ }>
362
+ | Issue<"guide-marker-malformed", {
363
+ guidePath: string;
364
+ lines: readonly number[];
365
+ }>
366
+ | Issue<"guide-pointer-missing", {
367
+ guidePath: string;
368
+ noteId: string;
369
+ }>
370
+ | Issue<"guide-pointer-mismatch", {
371
+ guidePath: string;
372
+ scope: string;
373
+ noteId: string;
374
+ expectedId: string;
375
+ actualNotePaths: readonly string[];
376
+ }>
377
+ | Issue<"context-note-missing-reciprocal-marker", {
378
+ notePath: string;
379
+ scope: string;
380
+ guidePath: string;
381
+ expectedMarker: string;
382
+ }>
383
+ | Issue<"scope-directory-missing", {
384
+ scope: string;
385
+ repositoryPath: string;
386
+ }>
387
+ | Issue<"scope-directory-not-directory", {
388
+ scope: string;
389
+ repositoryPath: string;
390
+ }>
391
+ | Issue<"scope-directory-symlink", {
392
+ scope: string;
393
+ repositoryPath: string;
394
+ }>
395
+ | Issue<"guide-file-missing", {
396
+ scope: string;
397
+ repositoryPath: string;
398
+ }>
399
+ | Issue<"guide-file-not-regular", {
400
+ scope: string;
401
+ repositoryPath: string;
402
+ }>
403
+ | Issue<"guide-file-symlink", {
404
+ scope: string;
405
+ repositoryPath: string;
406
+ }>
407
+ | Issue<"repository-symlink-escape", {
408
+ scope: string;
409
+ repositoryPath: string;
410
+ resolvedPath: string;
411
+ }>;
412
+
413
+ export type AgentContextAnalysis = {
414
+ readonly contexts: readonly AgentContextHub[];
415
+ readonly guides: readonly AgentContextGuide[];
416
+ readonly issues: readonly AgentContextIssue[];
417
+ };
418
+
419
+ type ContextCandidate = {
420
+ readonly index: number;
421
+ readonly note: Note;
422
+ readonly rawScope: string;
423
+ readonly preNfcScope: string;
424
+ readonly scope: string;
425
+ readonly canonicalId: string;
426
+ readonly canonicalPath: string;
427
+ readonly guidePath: string;
428
+ readonly marker: string;
429
+ readonly underScopes: boolean;
430
+ readonly canonical: boolean;
431
+ };
432
+
433
+ function noteIsUnderContextDirectory(note: Note): boolean {
434
+ return note.path.startsWith(`${agentContextDirectory}/`);
435
+ }
436
+
437
+ function normalizedGuideSource(
438
+ guide: AgentGuideSource,
439
+ ): { path: string; scope: string; source: string } {
440
+ const path = normalizeRepositoryScope(guide.path);
441
+ if (posix.basename(path) !== "AGENTS.md") {
442
+ throw new TypeError("An agent guide source path must end in AGENTS.md.");
443
+ }
444
+ const parent = posix.dirname(path);
445
+ return {
446
+ path,
447
+ scope: parent === "" ? "." : normalizeRepositoryScope(parent),
448
+ source: guide.source,
449
+ };
450
+ }
451
+
452
+ function issueSortKey(issue: AgentContextIssue): string {
453
+ const record = issue as Readonly<Record<string, unknown>>;
454
+ return [
455
+ issue.kind,
456
+ typeof record.notePath === "string" ? record.notePath : "",
457
+ typeof record.guidePath === "string" ? record.guidePath : "",
458
+ typeof record.scope === "string" ? record.scope : "",
459
+ issue.message,
460
+ ].join("\0");
461
+ }
462
+
463
+ function sortedIssues(issues: readonly AgentContextIssue[]): AgentContextIssue[] {
464
+ return [...issues].toSorted((left, right) =>
465
+ issueSortKey(left).localeCompare(issueSortKey(right)));
466
+ }
467
+
468
+ function sortedUnique(values: readonly string[]): string[] {
469
+ return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
470
+ }
471
+
472
+ function groupBy<T, K>(
473
+ values: readonly T[],
474
+ keyOf: (value: T) => K,
475
+ ): Map<K, T[]> {
476
+ const groups = new Map<K, T[]>();
477
+ for (const value of values) {
478
+ const key = keyOf(value);
479
+ const group = groups.get(key) ?? [];
480
+ group.push(value);
481
+ groups.set(key, group);
482
+ }
483
+ return groups;
484
+ }
485
+
486
+ function scopeDepth(scope: string): number {
487
+ return scope === "." ? 0 : scope.split("/").length;
488
+ }
489
+
490
+ function scopeIsAncestor(ancestor: string, descendant: string): boolean {
491
+ return ancestor === "."
492
+ || ancestor === descendant
493
+ || descendant.startsWith(`${ancestor}/`);
494
+ }
495
+
496
+ /**
497
+ * Analyze typed context notes and already-read guide sources without filesystem
498
+ * access. Unmapped guides are valid; every context mapping must be reciprocal.
499
+ */
500
+ export function analyzeAgentContexts(
501
+ notes: readonly Note[],
502
+ guideSources: readonly AgentGuideSource[] = [],
503
+ ): AgentContextAnalysis {
504
+ const issues: AgentContextIssue[] = [];
505
+ const candidates: ContextCandidate[] = [];
506
+
507
+ for (const [index, note] of notes.entries()) {
508
+ const underScopes = noteIsUnderContextDirectory(note);
509
+ const hasType = Object.hasOwn(note.metadata, "type");
510
+ const type = note.metadata.type;
511
+ if (underScopes && type !== agentContextType) {
512
+ issues.push({
513
+ kind: "non-context-note-under-scopes",
514
+ notePath: note.path,
515
+ message: `The note ${note.path} is under scopes/ but is not an agent-context note.`,
516
+ });
517
+ if (hasType) {
518
+ issues.push({
519
+ kind: "malformed-context-type",
520
+ notePath: note.path,
521
+ actual: type,
522
+ message: `The note ${note.path} has an invalid agent-context type.`,
523
+ });
524
+ }
525
+ continue;
526
+ }
527
+ if (type !== agentContextType) continue;
528
+
529
+ if (!underScopes) {
530
+ issues.push({
531
+ kind: "context-note-outside-scopes",
532
+ notePath: note.path,
533
+ message: `The agent-context note ${note.path} must live under scopes/.`,
534
+ });
535
+ }
536
+ const rawScope = note.metadata.scope;
537
+ if (typeof rawScope !== "string") {
538
+ issues.push({
539
+ kind: "malformed-context-scope",
540
+ notePath: note.path,
541
+ actual: rawScope,
542
+ reason: "The scope must be one string.",
543
+ message: `The agent-context note ${note.path} must declare one string scope.`,
544
+ });
545
+ continue;
546
+ }
547
+
548
+ let scope: string;
549
+ let preNfcScope: string;
550
+ try {
551
+ scope = normalizeRepositoryScope(rawScope);
552
+ preNfcScope = normalizedRepositoryPath(rawScope, null);
553
+ } catch (error) {
554
+ const reason = error instanceof Error ? error.message : "The scope is invalid.";
555
+ issues.push({
556
+ kind: "malformed-context-scope",
557
+ notePath: note.path,
558
+ actual: rawScope,
559
+ reason,
560
+ message: `The agent-context note ${note.path} has an invalid repository scope.`,
561
+ });
562
+ continue;
563
+ }
564
+
565
+ const canonicalId = agentContextNoteId(scope);
566
+ const canonicalPath = `${canonicalId}.md`;
567
+ const canonical = note.id === canonicalId && note.path === canonicalPath;
568
+ if (!canonical) {
569
+ issues.push({
570
+ kind: "noncanonical-context-note",
571
+ notePath: note.path,
572
+ noteId: note.id,
573
+ scope,
574
+ expectedPath: canonicalPath,
575
+ expectedId: canonicalId,
576
+ message: `The agent-context note for ${scope} must be ${canonicalPath}.`,
577
+ });
578
+ }
579
+ candidates.push({
580
+ index,
581
+ note,
582
+ rawScope,
583
+ preNfcScope,
584
+ scope,
585
+ canonicalId,
586
+ canonicalPath,
587
+ guidePath: agentContextGuidePath(scope),
588
+ marker: formatAgentContextMarker(canonicalId),
589
+ underScopes,
590
+ canonical,
591
+ });
592
+ }
593
+
594
+ const conflictedCandidateIndexes = new Set<number>();
595
+ const byScope = groupBy(candidates, (candidate) => candidate.scope);
596
+ for (const [scope, matches] of byScope) {
597
+ if (matches.length < 2) continue;
598
+ for (const match of matches) conflictedCandidateIndexes.add(match.index);
599
+ const preNfcScopes = sortedUnique(matches.map((match) => match.preNfcScope));
600
+ const notePaths = matches
601
+ .map((match) => match.note.path)
602
+ .toSorted((left, right) => left.localeCompare(right));
603
+ if (preNfcScopes.length > 1) {
604
+ issues.push({
605
+ kind: "nfc-context-scope-collision",
606
+ scope,
607
+ rawScopes: sortedUnique(matches.map((match) => match.rawScope)),
608
+ notePaths,
609
+ message: `Multiple context scopes normalize to the same NFC directory ${scope}.`,
610
+ });
611
+ } else {
612
+ issues.push({
613
+ kind: "duplicate-context-scope",
614
+ scope,
615
+ notePaths,
616
+ message: `The repository scope ${scope} has more than one context note.`,
617
+ });
618
+ }
619
+ }
620
+
621
+ const byCaseFoldedScope = groupBy(
622
+ candidates,
623
+ (candidate) => candidate.scope.toLocaleLowerCase("en-US"),
624
+ );
625
+ for (const matches of byCaseFoldedScope.values()) {
626
+ const scopes = sortedUnique(matches.map((match) => match.scope));
627
+ if (scopes.length < 2) continue;
628
+ for (const match of matches) conflictedCandidateIndexes.add(match.index);
629
+ issues.push({
630
+ kind: "case-fold-context-scope-collision",
631
+ scopes,
632
+ notePaths: matches
633
+ .map((match) => match.note.path)
634
+ .toSorted((left, right) => left.localeCompare(right)),
635
+ message: `Context scopes ${scopes.join(", ")} collide under case folding.`,
636
+ });
637
+ }
638
+
639
+ const guides: AgentContextGuide[] = [];
640
+ for (const guideSource of guideSources) {
641
+ try {
642
+ const guide = normalizedGuideSource(guideSource);
643
+ guides.push({ ...guide, marker: parseAgentContextMarker(guide.source) });
644
+ } catch (error) {
645
+ issues.push({
646
+ kind: "invalid-guide-path",
647
+ guidePath: guideSource.path,
648
+ reason: error instanceof Error ? error.message : "The guide path is invalid.",
649
+ message: `The guide source path ${guideSource.path} is invalid.`,
650
+ });
651
+ }
652
+ }
653
+ guides.sort((left, right) => left.path.localeCompare(right.path));
654
+
655
+ const mappableCandidates = candidates.filter((candidate) =>
656
+ candidate.underScopes && candidate.canonical);
657
+ const mappableByScope = groupBy(
658
+ mappableCandidates,
659
+ (candidate) => candidate.scope,
660
+ );
661
+ const notesById = groupBy(notes, (note) => note.id);
662
+ const reciprocalCandidateIndexes = new Set<number>();
663
+
664
+ for (const guide of guides) {
665
+ const scopedCandidates = mappableByScope.get(guide.scope) ?? [];
666
+ if (guide.marker.kind === "missing" && scopedCandidates.length > 0) {
667
+ issues.push({
668
+ kind: "guide-marker-missing",
669
+ guidePath: guide.path,
670
+ scope: guide.scope,
671
+ message: `The mapped guide ${guide.path} is missing its kb:context marker.`,
672
+ });
673
+ }
674
+ if (guide.marker.markers.length > 1) {
675
+ issues.push({
676
+ kind: "guide-marker-multiple",
677
+ guidePath: guide.path,
678
+ lines: guide.marker.markers.map((marker) => marker.line),
679
+ message: `The guide ${guide.path} has more than one kb:context marker.`,
680
+ });
681
+ }
682
+ if (guide.marker.malformed.length > 0) {
683
+ issues.push({
684
+ kind: "guide-marker-malformed",
685
+ guidePath: guide.path,
686
+ lines: guide.marker.malformed.map((marker) => marker.line),
687
+ message: `The guide ${guide.path} has a malformed kb:context marker.`,
688
+ });
689
+ }
690
+
691
+ if (
692
+ guide.marker.markers.length !== 1
693
+ || guide.marker.malformed.length !== 0
694
+ ) {
695
+ continue;
696
+ }
697
+ const marker = guide.marker.markers[0];
698
+ if (marker === undefined) continue;
699
+ const pointedNotes = notesById.get(marker.noteId) ?? [];
700
+ if (pointedNotes.length === 0) {
701
+ issues.push({
702
+ kind: "guide-pointer-missing",
703
+ guidePath: guide.path,
704
+ noteId: marker.noteId,
705
+ message: `The guide ${guide.path} points to missing note ${marker.noteId}.`,
706
+ });
707
+ continue;
708
+ }
709
+
710
+ const pointedCandidates = mappableCandidates.filter((candidate) =>
711
+ candidate.canonicalId === marker.noteId
712
+ && pointedNotes.includes(candidate.note));
713
+ const reciprocal = pointedCandidates.filter((candidate) =>
714
+ candidate.scope === guide.scope
715
+ && candidate.canonicalId === agentContextNoteId(guide.scope));
716
+ if (reciprocal.length === 0) {
717
+ issues.push({
718
+ kind: "guide-pointer-mismatch",
719
+ guidePath: guide.path,
720
+ scope: guide.scope,
721
+ noteId: marker.noteId,
722
+ expectedId: agentContextNoteId(guide.scope),
723
+ actualNotePaths: pointedNotes
724
+ .map((note) => note.path)
725
+ .toSorted((left, right) => left.localeCompare(right)),
726
+ message: `The guide ${guide.path} does not point to the context note for ${guide.scope}.`,
727
+ });
728
+ continue;
729
+ }
730
+ for (const candidate of reciprocal) {
731
+ reciprocalCandidateIndexes.add(candidate.index);
732
+ }
733
+ }
734
+
735
+ for (const candidate of mappableCandidates) {
736
+ if (reciprocalCandidateIndexes.has(candidate.index)) continue;
737
+ issues.push({
738
+ kind: "context-note-missing-reciprocal-marker",
739
+ notePath: candidate.note.path,
740
+ scope: candidate.scope,
741
+ guidePath: candidate.guidePath,
742
+ expectedMarker: candidate.marker,
743
+ message: `The context note ${candidate.note.path} is missing a reciprocal marker in ${candidate.guidePath}.`,
744
+ });
745
+ }
746
+
747
+ const contexts = candidates
748
+ .map((candidate): AgentContextHub => {
749
+ const reciprocal = reciprocalCandidateIndexes.has(candidate.index);
750
+ const canonical = candidate.underScopes && candidate.canonical;
751
+ return {
752
+ note: candidate.note,
753
+ rawScope: candidate.rawScope,
754
+ scope: candidate.scope,
755
+ canonicalId: candidate.canonicalId,
756
+ canonicalPath: candidate.canonicalPath,
757
+ guidePath: candidate.guidePath,
758
+ marker: candidate.marker,
759
+ canonical,
760
+ reciprocal,
761
+ valid: canonical
762
+ && reciprocal
763
+ && !conflictedCandidateIndexes.has(candidate.index),
764
+ };
765
+ })
766
+ .toSorted((left, right) =>
767
+ left.scope.localeCompare(right.scope)
768
+ || left.note.path.localeCompare(right.note.path));
769
+
770
+ return {
771
+ contexts,
772
+ guides,
773
+ issues: sortedIssues(issues),
774
+ };
775
+ }
776
+
777
+ export type AgentContextTargetKind = "auto" | "file" | "directory";
778
+
779
+ export type InspectAgentContextRepositoryOptions = {
780
+ readonly repositoryRoot: string;
781
+ readonly target?: string;
782
+ readonly targetKind?: AgentContextTargetKind;
783
+ readonly validationMode?: "applicable" | "all";
784
+ };
785
+
786
+ export type InheritedAgentGuide = AgentContextGuide & {
787
+ readonly absolutePath: string;
788
+ };
789
+
790
+ export type AgentContextRepositoryInspection = AgentContextAnalysis & {
791
+ readonly repositoryRoot: string;
792
+ readonly target: string;
793
+ readonly targetScope: string;
794
+ readonly inheritedGuides: readonly InheritedAgentGuide[];
795
+ readonly matchingContexts: readonly AgentContextHub[];
796
+ };
797
+
798
+ export type AgentContextRepositoryPathErrorCode =
799
+ | "root-not-directory"
800
+ | "target-symlink"
801
+ | "target-symlink-escape"
802
+ | "target-parent-not-directory";
803
+
804
+ /** Repository state made a requested target unsafe or ambiguous to inspect. */
805
+ export class AgentContextRepositoryPathError extends Error {
806
+ readonly code: AgentContextRepositoryPathErrorCode;
807
+ readonly path: string;
808
+
809
+ constructor(
810
+ code: AgentContextRepositoryPathErrorCode,
811
+ path: string,
812
+ message: string,
813
+ ) {
814
+ super(message);
815
+ this.name = "AgentContextRepositoryPathError";
816
+ this.code = code;
817
+ this.path = path;
818
+ }
819
+ }
820
+
821
+ function isMissingFileError(error: unknown): boolean {
822
+ return error !== null
823
+ && typeof error === "object"
824
+ && "code" in error
825
+ && error.code === "ENOENT";
826
+ }
827
+
828
+ function isUnresolvableSymlinkError(error: unknown): boolean {
829
+ return isMissingFileError(error)
830
+ || (
831
+ error !== null
832
+ && typeof error === "object"
833
+ && "code" in error
834
+ && error.code === "ELOOP"
835
+ );
836
+ }
837
+
838
+ async function realpathIfPresent(path: string): Promise<string | null> {
839
+ try {
840
+ return await realpath(path);
841
+ } catch (error) {
842
+ if (isUnresolvableSymlinkError(error)) return null;
843
+ throw error;
844
+ }
845
+ }
846
+
847
+ function pathIsWithin(root: string, candidate: string): boolean {
848
+ const fromRoot = relative(root, candidate);
849
+ return fromRoot === ""
850
+ || (
851
+ fromRoot !== ".."
852
+ && !fromRoot.startsWith(`..${sep}`)
853
+ && !isAbsolute(fromRoot)
854
+ );
855
+ }
856
+
857
+ function repositoryPath(root: string, scope: string): string {
858
+ return scope === "." ? root : join(root, ...scope.split("/"));
859
+ }
860
+
861
+ function repositoryRelativePath(root: string, path: string): string {
862
+ const fromRoot = relative(root, path).split(sep).join("/");
863
+ return fromRoot === "" ? "." : fromRoot;
864
+ }
865
+
866
+ async function assertTargetPrefixConfined(
867
+ root: string,
868
+ normalizedTarget: string,
869
+ ): Promise<void> {
870
+ if (normalizedTarget === ".") return;
871
+ const segments = normalizedTarget.split("/");
872
+ let current = root;
873
+ for (const [index, segment] of segments.entries()) {
874
+ const next = join(current, segment);
875
+ let metadata;
876
+ try {
877
+ metadata = await lstat(next);
878
+ } catch (error) {
879
+ if (isMissingFileError(error)) return;
880
+ throw error;
881
+ }
882
+ if (metadata.isSymbolicLink()) {
883
+ const resolvedPath = await realpathIfPresent(next);
884
+ if (resolvedPath !== null && !pathIsWithin(root, resolvedPath)) {
885
+ throw new AgentContextRepositoryPathError(
886
+ "target-symlink-escape",
887
+ repositoryRelativePath(root, next),
888
+ `The target traverses a symbolic link outside the repository: ${next}.`,
889
+ );
890
+ }
891
+ throw new AgentContextRepositoryPathError(
892
+ "target-symlink",
893
+ repositoryRelativePath(root, next),
894
+ `The target traverses a symbolic link: ${next}.`,
895
+ );
896
+ }
897
+ if (index < segments.length - 1 && !metadata.isDirectory()) {
898
+ throw new AgentContextRepositoryPathError(
899
+ "target-parent-not-directory",
900
+ repositoryRelativePath(root, next),
901
+ `A target parent is not a directory: ${next}.`,
902
+ );
903
+ }
904
+ current = next;
905
+ }
906
+ }
907
+
908
+ async function targetScope(
909
+ root: string,
910
+ target: string,
911
+ kind: AgentContextTargetKind,
912
+ directoryHint: boolean,
913
+ ): Promise<string> {
914
+ if (kind === "directory") return target;
915
+ if (kind === "file") {
916
+ const parent = posix.dirname(target);
917
+ return parent === "" ? "." : parent;
918
+ }
919
+
920
+ const absoluteTarget = repositoryPath(root, target);
921
+ try {
922
+ const metadata = await lstat(absoluteTarget);
923
+ if (metadata.isDirectory()) return target;
924
+ const parent = posix.dirname(target);
925
+ return parent === "" ? "." : parent;
926
+ } catch (error) {
927
+ if (!isMissingFileError(error)) throw error;
928
+ }
929
+ if (directoryHint) return target;
930
+ const basename = posix.basename(target);
931
+ if (basename.includes(".")) {
932
+ const parent = posix.dirname(target);
933
+ return parent === "" ? "." : parent;
934
+ }
935
+ return target;
936
+ }
937
+
938
+ function scopeAncestors(scope: string): string[] {
939
+ if (scope === ".") return ["."];
940
+ const segments = scope.split("/");
941
+ const ancestors = ["."];
942
+ for (let length = 1; length <= segments.length; length += 1) {
943
+ ancestors.push(segments.slice(0, length).join("/"));
944
+ }
945
+ return ancestors;
946
+ }
947
+
948
+ async function inspectScopeDirectory(
949
+ root: string,
950
+ scope: string,
951
+ ): Promise<{ readonly path: string; readonly issues: readonly AgentContextIssue[] }> {
952
+ const path = repositoryPath(root, scope);
953
+ const issues: AgentContextIssue[] = [];
954
+ if (scope === ".") return { path, issues };
955
+
956
+ let current = root;
957
+ for (const segment of scope.split("/")) {
958
+ current = join(current, segment);
959
+ let metadata;
960
+ try {
961
+ metadata = await lstat(current);
962
+ } catch (error) {
963
+ if (!isMissingFileError(error)) throw error;
964
+ issues.push({
965
+ kind: "scope-directory-missing",
966
+ scope,
967
+ repositoryPath: repositoryRelativePath(root, current),
968
+ message: `The mapped scope directory ${repositoryRelativePath(root, current)} does not exist.`,
969
+ });
970
+ return { path, issues };
971
+ }
972
+ if (metadata.isSymbolicLink()) {
973
+ issues.push({
974
+ kind: "scope-directory-symlink",
975
+ scope,
976
+ repositoryPath: repositoryRelativePath(root, current),
977
+ message: `The mapped scope directory ${repositoryRelativePath(root, current)} is a symbolic link.`,
978
+ });
979
+ const resolvedPath = await realpathIfPresent(current);
980
+ if (resolvedPath !== null && !pathIsWithin(root, resolvedPath)) {
981
+ issues.push({
982
+ kind: "repository-symlink-escape",
983
+ scope,
984
+ repositoryPath: repositoryRelativePath(root, current),
985
+ resolvedPath,
986
+ message: `The mapped scope directory ${repositoryRelativePath(root, current)} resolves outside the repository.`,
987
+ });
988
+ }
989
+ return { path, issues };
990
+ }
991
+ if (!metadata.isDirectory()) {
992
+ issues.push({
993
+ kind: "scope-directory-not-directory",
994
+ scope,
995
+ repositoryPath: repositoryRelativePath(root, current),
996
+ message: `The mapped scope path ${repositoryRelativePath(root, current)} is not a directory.`,
997
+ });
998
+ return { path, issues };
999
+ }
1000
+ }
1001
+
1002
+ const resolvedPath = await realpath(path);
1003
+ if (!pathIsWithin(root, resolvedPath)) {
1004
+ issues.push({
1005
+ kind: "repository-symlink-escape",
1006
+ scope,
1007
+ repositoryPath: repositoryRelativePath(root, path),
1008
+ resolvedPath,
1009
+ message: `The mapped scope directory ${repositoryRelativePath(root, path)} resolves outside the repository.`,
1010
+ });
1011
+ }
1012
+ return { path, issues };
1013
+ }
1014
+
1015
+ type ReadGuideResult = {
1016
+ readonly guide?: AgentGuideSource;
1017
+ readonly absolutePath: string;
1018
+ readonly issues: readonly AgentContextIssue[];
1019
+ };
1020
+
1021
+ async function readGuide(
1022
+ root: string,
1023
+ scope: string,
1024
+ required: boolean,
1025
+ ): Promise<ReadGuideResult> {
1026
+ const absolutePath = join(repositoryPath(root, scope), "AGENTS.md");
1027
+ const path = agentContextGuidePath(scope);
1028
+ const issues: AgentContextIssue[] = [];
1029
+ let metadata;
1030
+ try {
1031
+ metadata = await lstat(absolutePath);
1032
+ } catch (error) {
1033
+ if (!isMissingFileError(error)) throw error;
1034
+ if (required) {
1035
+ issues.push({
1036
+ kind: "guide-file-missing",
1037
+ scope,
1038
+ repositoryPath: path,
1039
+ message: `The mapped guide ${path} does not exist.`,
1040
+ });
1041
+ }
1042
+ return { absolutePath, issues };
1043
+ }
1044
+
1045
+ if (metadata.isSymbolicLink()) {
1046
+ issues.push({
1047
+ kind: "guide-file-symlink",
1048
+ scope,
1049
+ repositoryPath: path,
1050
+ message: `The mapped guide ${path} is a symbolic link.`,
1051
+ });
1052
+ const resolvedPath = await realpathIfPresent(absolutePath);
1053
+ if (resolvedPath !== null && !pathIsWithin(root, resolvedPath)) {
1054
+ issues.push({
1055
+ kind: "repository-symlink-escape",
1056
+ scope,
1057
+ repositoryPath: path,
1058
+ resolvedPath,
1059
+ message: `The mapped guide ${path} resolves outside the repository.`,
1060
+ });
1061
+ }
1062
+ return { absolutePath, issues };
1063
+ }
1064
+ if (!metadata.isFile()) {
1065
+ issues.push({
1066
+ kind: "guide-file-not-regular",
1067
+ scope,
1068
+ repositoryPath: path,
1069
+ message: `The mapped guide ${path} is not a regular file.`,
1070
+ });
1071
+ return { absolutePath, issues };
1072
+ }
1073
+
1074
+ const handle = await open(
1075
+ absolutePath,
1076
+ constants.O_RDONLY | constants.O_NOFOLLOW,
1077
+ );
1078
+ try {
1079
+ const openedMetadata = await handle.stat();
1080
+ if (!openedMetadata.isFile()) {
1081
+ issues.push({
1082
+ kind: "guide-file-not-regular",
1083
+ scope,
1084
+ repositoryPath: path,
1085
+ message: `The mapped guide ${path} is not a regular file.`,
1086
+ });
1087
+ return { absolutePath, issues };
1088
+ }
1089
+ const resolvedPath = await realpath(absolutePath);
1090
+ if (!pathIsWithin(root, resolvedPath)) {
1091
+ issues.push({
1092
+ kind: "repository-symlink-escape",
1093
+ scope,
1094
+ repositoryPath: path,
1095
+ resolvedPath,
1096
+ message: `The mapped guide ${path} resolves outside the repository.`,
1097
+ });
1098
+ return { absolutePath, issues };
1099
+ }
1100
+ return {
1101
+ absolutePath,
1102
+ guide: {
1103
+ path,
1104
+ source: await handle.readFile({ encoding: "utf8" }),
1105
+ },
1106
+ issues,
1107
+ };
1108
+ } finally {
1109
+ await handle.close();
1110
+ }
1111
+ }
1112
+
1113
+ function uniqueIssues(issues: readonly AgentContextIssue[]): AgentContextIssue[] {
1114
+ const unique = new Map<string, AgentContextIssue>();
1115
+ for (const issue of issues) {
1116
+ unique.set(JSON.stringify(issue), issue);
1117
+ }
1118
+ return sortedIssues([...unique.values()]);
1119
+ }
1120
+
1121
+ /**
1122
+ * Inspect repository state for context mappings and one target path.
1123
+ *
1124
+ * Context scope directories and mapped guides must be real, confined filesystem
1125
+ * entries. The inherited guide chain is root-to-nearest; verified matching
1126
+ * context hubs are nearest-to-root. Validation is confined to applicable
1127
+ * ancestors by default; repository-wide gates opt into `validationMode: "all"`.
1128
+ */
1129
+ export async function inspectAgentContextRepository(
1130
+ notes: readonly Note[],
1131
+ options: InspectAgentContextRepositoryOptions,
1132
+ ): Promise<AgentContextRepositoryInspection> {
1133
+ const requestedRoot = resolve(options.repositoryRoot);
1134
+ const root = await realpath(requestedRoot);
1135
+ const rootMetadata = await lstat(root);
1136
+ if (!rootMetadata.isDirectory()) {
1137
+ throw new AgentContextRepositoryPathError(
1138
+ "root-not-directory",
1139
+ options.repositoryRoot,
1140
+ "The repository root must be a directory.",
1141
+ );
1142
+ }
1143
+
1144
+ const target = normalizeRepositoryScope(options.target ?? ".");
1145
+ await assertTargetPrefixConfined(root, target);
1146
+ const resolvedTargetScope = await targetScope(
1147
+ root,
1148
+ target,
1149
+ options.targetKind ?? "auto",
1150
+ /[\\/]$/u.test(options.target ?? "."),
1151
+ );
1152
+ const preliminary = analyzeAgentContexts(notes);
1153
+ const validateAllMappings = options.validationMode === "all";
1154
+ const applicableCaseFoldedScopes = new Set(
1155
+ preliminary.contexts
1156
+ .filter((context) =>
1157
+ scopeIsAncestor(context.scope, resolvedTargetScope))
1158
+ .map((context) => context.scope.toLocaleLowerCase("en-US")),
1159
+ );
1160
+ const contextsToValidate = preliminary.contexts.filter((context) =>
1161
+ validateAllMappings
1162
+ || applicableCaseFoldedScopes.has(
1163
+ context.scope.toLocaleLowerCase("en-US"),
1164
+ ));
1165
+ const notesToAnalyze = validateAllMappings
1166
+ ? notes
1167
+ : contextsToValidate.map(({ note }) => note);
1168
+ const scopesToInspect = sortedUnique(
1169
+ contextsToValidate
1170
+ .filter((context) => context.canonical)
1171
+ .map((context) => context.scope),
1172
+ );
1173
+ const filesystemIssues: AgentContextIssue[] = [];
1174
+ const guidesByPath = new Map<string, {
1175
+ readonly guide: AgentGuideSource;
1176
+ readonly absolutePath: string;
1177
+ }>();
1178
+
1179
+ for (const scope of scopesToInspect) {
1180
+ const directory = await inspectScopeDirectory(root, scope);
1181
+ filesystemIssues.push(...directory.issues);
1182
+ if (directory.issues.length > 0) continue;
1183
+ const read = await readGuide(root, scope, true);
1184
+ filesystemIssues.push(...read.issues);
1185
+ if (read.guide !== undefined) {
1186
+ guidesByPath.set(read.guide.path, {
1187
+ guide: read.guide,
1188
+ absolutePath: read.absolutePath,
1189
+ });
1190
+ }
1191
+ }
1192
+
1193
+ const inheritedGuidePaths: string[] = [];
1194
+ for (const scope of scopeAncestors(resolvedTargetScope)) {
1195
+ const guidePath = agentContextGuidePath(scope);
1196
+ let entry = guidesByPath.get(guidePath);
1197
+ if (entry === undefined) {
1198
+ const read = await readGuide(root, scope, false);
1199
+ filesystemIssues.push(...read.issues);
1200
+ if (read.guide !== undefined) {
1201
+ entry = { guide: read.guide, absolutePath: read.absolutePath };
1202
+ guidesByPath.set(guidePath, entry);
1203
+ }
1204
+ }
1205
+ if (entry !== undefined) inheritedGuidePaths.push(guidePath);
1206
+ }
1207
+
1208
+ const analysis = analyzeAgentContexts(
1209
+ notesToAnalyze,
1210
+ [...guidesByPath.values()].map((entry) => entry.guide),
1211
+ );
1212
+ const guidesByAnalyzedPath = new Map(
1213
+ analysis.guides.map((guide) => [guide.path, guide]),
1214
+ );
1215
+ const inheritedGuides = inheritedGuidePaths
1216
+ .map((path): InheritedAgentGuide | null => {
1217
+ const entry = guidesByPath.get(path);
1218
+ const guide = guidesByAnalyzedPath.get(path);
1219
+ return entry === undefined || guide === undefined
1220
+ ? null
1221
+ : { ...guide, absolutePath: entry.absolutePath };
1222
+ })
1223
+ .filter((guide): guide is InheritedAgentGuide => guide !== null);
1224
+ const matchingContexts = analysis.contexts
1225
+ .filter((context) =>
1226
+ context.valid && scopeIsAncestor(context.scope, resolvedTargetScope))
1227
+ .toSorted((left, right) =>
1228
+ scopeDepth(right.scope) - scopeDepth(left.scope)
1229
+ || left.scope.localeCompare(right.scope));
1230
+
1231
+ return {
1232
+ repositoryRoot: root,
1233
+ target,
1234
+ targetScope: resolvedTargetScope,
1235
+ contexts: analysis.contexts,
1236
+ guides: analysis.guides,
1237
+ inheritedGuides,
1238
+ matchingContexts,
1239
+ issues: uniqueIssues([...analysis.issues, ...filesystemIssues]),
1240
+ };
1241
+ }