context-loader 1.1.0__tar.gz → 1.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: context-loader
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: Render deterministic local Git context for any executor
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -219,15 +219,14 @@ Python standard library.
219
219
 
220
220
  ## Open-source quick start
221
221
 
222
- Context Loader is a local CLI that exports repository context deterministically for coding-agent workflows,
223
- rendering stable Markdown or JSON output.
222
+ Current stable release: **1.3.0**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
224
223
 
225
224
  ```bash
226
- uv tool install context-loader
225
+ uv tool install 'context-loader==1.3.0'
227
226
  ```
228
227
 
229
228
  ```bash
230
- pip install context-loader
229
+ pip install 'context-loader==1.3.0'
231
230
  ```
232
231
 
233
232
  ```bash
@@ -240,21 +239,21 @@ project-context --repo /path/to/repo --format json
240
239
 
241
240
  ## Install
242
241
 
243
- Source version: `1.1.0`. Install its matching published release or an exact source commit.
242
+ Source version: `1.3.0`. Install its matching published release or an exact source commit.
244
243
 
245
244
  Install via `uv`:
246
245
 
247
246
  ```bash
248
- uv tool install context-loader
247
+ uv tool install 'context-loader==1.3.0'
249
248
  ```
250
249
 
251
250
  Install via `pip`:
252
251
 
253
252
  ```bash
254
- pip install context-loader
253
+ pip install 'context-loader==1.3.0'
255
254
  ```
256
255
 
257
- For development or source-based installs tracking current repository (`1.1.0`):
256
+ For development or source-based installs tracking current repository (`1.3.0`):
258
257
 
259
258
  ```bash
260
259
  uv tool install git+https://github.com/xuanheng-tech/context-loader.git
@@ -265,8 +264,8 @@ The repository also retains `./project-context` as a direct development entry po
265
264
  ### Distributions
266
265
 
267
266
  The sole distribution is **`context-loader`**, containing the `context_loader` runtime
268
- and the `project-context` console script. Version `1.0.0` breaks the CLI name and Markdown
269
- heading contract. The JSON schema is unchanged. Terminal and any executor call this same
267
+ and the `project-context` console script. Version `1.0.0` broke the CLI name and Markdown
268
+ heading contract while leaving the then-current JSON schema unchanged. Terminal and any executor call this same
270
269
  entry point with explicit repository and focus arguments; no provider adapter or private
271
270
  session state is involved.
272
271
 
@@ -305,16 +304,17 @@ project-context --version
305
304
  project-context --repo /home/user/projects/example
306
305
  project-context --repo /home/user/projects/example --format markdown
307
306
  project-context --repo /home/user/projects/example --format json
307
+ project-context --repo /home/user/projects/example --format json-compact
308
308
  project-context --repo /home/user/projects/example \
309
309
  --focus "Authentication and session management" \
310
310
  --path auth/session.py
311
311
  ```
312
312
 
313
313
  `--format` defaults to `markdown`. In Markdown mode, `--repo` retains the 0.1.1 contract: it must be
314
- the absolute, canonical root of a non-bare Git working tree. In JSON mode, an absolute existing
315
- directory inside the working tree is accepted; symlinks are normalized and the discovered root is
316
- reported as `canonical_root`. Relative paths, non-Git directories, regular files, and bare
317
- repositories are rejected in both modes.
314
+ the absolute, canonical root of a non-bare Git working tree. In the `json` and `json-compact` modes,
315
+ an absolute existing directory inside the working tree is accepted; symlinks are normalized and the
316
+ discovered root is reported as `canonical_root`. Relative paths, non-Git directories, regular files,
317
+ and bare repositories are rejected in every mode.
318
318
 
319
319
  `--focus` and `--path` are optional, bounded selection signals for the root `AGENTS.md`. `--path`
320
320
  must be repository-relative. The collector does not retain either input in output or audit data.
@@ -337,15 +337,17 @@ The output has no generated timestamp, AI summary, architecture inference, or di
337
337
 
338
338
  ## JSON Output
339
339
 
340
- `--format json` writes exactly one compact UTF-8 JSON document plus one trailing newline to stdout.
341
- Keys are serialized in sorted order with `ensure_ascii=False`. The declared contract is:
340
+ `--format json` writes exactly one separator-compact UTF-8 JSON document plus one trailing newline
341
+ to stdout. Keys are serialized in sorted order with `ensure_ascii=False`. `--format json-compact`
342
+ uses the identical serialization; only the field set differs, as described under “Compact model
343
+ consumption”. The declared contract for `--format json` is:
342
344
 
343
345
  ```json
344
346
  {
345
- "schema_version": 1,
347
+ "schema_version": 3,
346
348
  "tool": {
347
349
  "name": "context-loader",
348
- "version": "1.1.0"
350
+ "version": "1.3.0"
349
351
  },
350
352
  "repository": {
351
353
  "requested_path": "/canonical/requested/path",
@@ -375,24 +377,78 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
375
377
  "context": "the same assembled Markdown context",
376
378
  "context_sha256": "sha256-hex",
377
379
  "statuses": [],
380
+ "nested_context": {
381
+ "files": ["docs/AGENTS.md"],
382
+ "list_truncated": false,
383
+ "scan_truncated": false
384
+ },
378
385
  "warnings": []
379
386
  }
380
387
  ```
381
388
 
382
389
  `context_sha256` hashes the UTF-8 bytes of `context`; each `content_sha256` does the same for that
383
- source's `content`. `sources` contains only file bodies that actually enter the final context, in
384
- assembly order, after the existing newline normalization and truncation rules. `scope` distinguishes
385
- `repository` from `global`; version 1.0.0's fixed root-file selection currently emits only
386
- `repository` sources and does not add any global-file discovery.
390
+ source's `content` body as rendered (after newline normalization, any `AGENTS.md` section selection
391
+ and any truncation marker) — never for the raw bytes of the file at `path`, which match only for
392
+ unselected, untruncated LF files. `sources` contains only file bodies that actually enter the final
393
+ context, in assembly order, after the existing newline normalization and truncation rules. `scope`
394
+ distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
395
+ only `repository` sources and does not add any global-file discovery.
387
396
 
388
- `statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown: skipped or absent sources, truncated sources, unreadable directory-tree entries, sections omitted under the global output budget, and truncated working-tree or declared-command listings. Each entry has stable `code`, `subject_kind`, and `subject` fields. `sources`, `context`, and `schema_version` remain unchanged; callers can ignore `statuses` safely.
397
+ `statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown — plus the nested-context truncation flags, which exist only in machine form: skipped or absent sources, truncated sources, unreadable directory-tree entries, sections omitted under the global output budget, truncated working-tree or declared-command listings, and truncated nested-`AGENTS.md` presence reports. Each entry has stable `code`, `subject_kind`, and `subject` fields. The two `nested_agents_*` codes mirror the `nested_context` truncation booleans for consumers that branch only on `statuses`; the full `nested_context` object remains a first-class field, never folded into `statuses`. Callers can ignore `statuses` safely.
389
398
 
390
399
  The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
391
400
  contain heading, heading level, and fixed selection reasons; it never contains the original focus or
392
- target path. Existing source fields and schema version 1 remain unchanged.
393
-
394
- The JSON schema version and package version are independent: `schema_version` is currently the
395
- integer `1`, while `tool.version` is `1.1.0`. Callers must depend only on fields declared above.
401
+ target path. Existing per-source fields remain unchanged; the document's exact key
402
+ set (now including `nested_context`) is named by its `schema_version`, described below.
403
+
404
+ `nested_context` is always present and existence-only: `files` lists repository-relative paths of
405
+ non-directory `AGENTS.md` entries under subdirectories, found by a bounded scan that enumerates
406
+ directory entries, never opens or reads a candidate file and never traverses a symlink; it carries
407
+ no sizes, no mtimes and no contents. A symlinked (even dangling) `AGENTS.md` is listed, because its
408
+ existence comes from the directory entry itself; the scan never resolves what it points at. Any
409
+ `.git`, `.venv`, `venv`, `node_modules` or `site-packages` directory is skipped at every depth:
410
+ package-manager and interpreter-managed trees are not authored repository instructions.
411
+ `list_truncated` means more matching entries exist beyond the report caps (32 paths and 4 KiB of
412
+ path bytes); `scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, or
413
+ a directory or entry could not be read, so absence of a path is not proof of absence of the file.
414
+ Paths are sanitized with the same escaping the Markdown uses for repository-derived text, so the
415
+ document is always valid UTF-8. The corresponding
416
+ `nested_agents_list_truncated` and `nested_agents_scan_truncated` status entries mirror both flags.
417
+ Presence is not instruction: whether a nested file applies, and its text, remain the caller's
418
+ judgment; Markdown output is unchanged.
419
+
420
+ ### Compact model consumption (`--format json-compact`)
421
+
422
+ `--format json-compact` emits a `schema_version` 4 document: exactly the version-3 document with
423
+ `sources[*].content` omitted. Every other field — `context`, `context_sha256`, `statuses`,
424
+ `nested_context`, `warnings`, `tool`, `repository`, and each source's `ordinal`, `kind`, `scope`,
425
+ `path`, `content_sha256` and optional `selection` — is identical to `--format json` for the same
426
+ arguments.
427
+ `sources` is provenance and index metadata, not the place a body must be fetched from: every omitted
428
+ body is already inside `context` verbatim, so consumers should read `context` for rule text and
429
+ reopen a source file only when the rendered body was truncated or selected away — never to recover a
430
+ body that the document already carried.
431
+ The projection exists because the selected source bodies already occur verbatim inside `context`,
432
+ so carrying them again in `sources` duplicates a large share of the document bytes (measured up to
433
+ ~44% on real worktrees, scaling with how much of `context` those bodies occupy) for a model
434
+ consumer without adding information. Nothing is lost: each omitted body remains inside `context`,
435
+ `content_sha256` still fingerprints that rendered body, and `path` plus `canonical_root` locate the
436
+ underlying file for a bounded manual re-read (whose raw bytes may differ from the rendered body as
437
+ defined above). `schema_version` is an exact document selector, not an upgrade marker: version 4 is
438
+ a field-set projection of version 3, so consumers must branch on the value and must not apply
439
+ `>=`-superset reasoning. Introducing this compact format does
440
+ not otherwise change default Markdown bytes, exit codes or determinism.
441
+
442
+ One historical exception is acknowledged: releases 1.0.0 and 1.1.0 both emitted `schema_version` 1
443
+ although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward each emitted number
444
+ names exactly one key set, and the `nested_context` renumber below enforces that rule going forward.
445
+
446
+ The JSON schema version and package version are independent: this build emits `schema_version` `3`
447
+ for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.0`. Version 1
448
+ and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
449
+ `nested_context` changes the default document's key set, version 3 names the current full-document
450
+ key set and version 4 names the compact projection.
451
+ Callers must depend only on fields declared above.
396
452
  The document contains no generated time or random identifier, so unchanged input produces identical
397
453
  JSON bytes. On failure, stdout remains empty and stderr contains only a short diagnostic.
398
454
 
@@ -406,7 +462,9 @@ Resolving an instruction hierarchy stays with the calling agent harness. That in
406
462
  shared or user-level instruction file outside the repository, nested or scoped `AGENTS.md` files
407
463
  under subdirectories, and any include or import directive written inside an instruction file:
408
464
  such a directive is transported as literal text and is never followed. `--path` selects sections
409
- of the repository-root `AGENTS.md` only; it never changes which files are read.
465
+ of the repository-root `AGENTS.md` only; it never changes which files are read. The JSON-only
466
+ `nested_context` field reports the existence of nested `AGENTS.md` paths without reading them;
467
+ deciding whether any of them applies stays with the calling agent harness exactly as before.
410
468
 
411
469
  A successful run therefore proves that the bounded root context was collected and rendered. It
412
470
  does not prove that every instruction applicable to a task has been loaded.
@@ -447,6 +505,8 @@ scan that ends before EOF still reports the characters it could not select.
447
505
  - All entry-file bodies: 24 KiB
448
506
  - Declared commands: 8 KiB
449
507
  - Directory tree: 12 KiB, 300 entries, and depth 2
508
+ - Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, and at most 32 reported paths;
509
+ file contents are never read
450
510
  - Working-tree changes: 100 paths and 4 KiB
451
511
  - Recent commits: 8
452
512
  - Git subprocess output: 16 MiB (bounded while reading)
@@ -485,7 +545,8 @@ or candidate-file content.
485
545
 
486
546
  ## Not Included
487
547
 
488
- Version 1.0.0 does not provide AI summaries, project-type detection, nested `AGENTS.md` handling,
548
+ Version 1.3.0 does not provide AI summaries, project-type detection, loading or transport of
549
+ nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
489
550
  Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
490
551
  network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
491
552
 
@@ -499,9 +560,13 @@ just check
499
560
 
500
561
  ## Version maintenance
501
562
 
502
- The versions in `pyproject.toml` and `context_loader/__init__.py`, the matching `CHANGELOG.md`
503
- section, and required tests must change in the same release-preparation batch. `CHANGELOG.md` is the
504
- authoritative version-change record. Merging to `master` is not a release; formal publication
563
+ The versions in `pyproject.toml` and `context_loader/__init__.py`, the `tool_version` in
564
+ `tool_cli_contract.json`, the matching `CHANGELOG.md` section, required tests, and the README
565
+ current-stable declaration plus `context-loader==X.Y.Z`
566
+ install pins must change in the same release-preparation batch. `CHANGELOG.md` is the
567
+ authoritative version-change record. `scripts/release.py` rejects README stable/install
568
+ pins that disagree with the intended package version; historical changelog entries are
569
+ not part of that check. Merging to `master` is not a release; formal publication
505
570
  still requires a separately created and pushed tag.
506
571
 
507
572
  ## Release closure and retries
@@ -8,15 +8,14 @@ Python standard library.
8
8
 
9
9
  ## Open-source quick start
10
10
 
11
- Context Loader is a local CLI that exports repository context deterministically for coding-agent workflows,
12
- rendering stable Markdown or JSON output.
11
+ Current stable release: **1.3.0**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
13
12
 
14
13
  ```bash
15
- uv tool install context-loader
14
+ uv tool install 'context-loader==1.3.0'
16
15
  ```
17
16
 
18
17
  ```bash
19
- pip install context-loader
18
+ pip install 'context-loader==1.3.0'
20
19
  ```
21
20
 
22
21
  ```bash
@@ -29,21 +28,21 @@ project-context --repo /path/to/repo --format json
29
28
 
30
29
  ## Install
31
30
 
32
- Source version: `1.1.0`. Install its matching published release or an exact source commit.
31
+ Source version: `1.3.0`. Install its matching published release or an exact source commit.
33
32
 
34
33
  Install via `uv`:
35
34
 
36
35
  ```bash
37
- uv tool install context-loader
36
+ uv tool install 'context-loader==1.3.0'
38
37
  ```
39
38
 
40
39
  Install via `pip`:
41
40
 
42
41
  ```bash
43
- pip install context-loader
42
+ pip install 'context-loader==1.3.0'
44
43
  ```
45
44
 
46
- For development or source-based installs tracking current repository (`1.1.0`):
45
+ For development or source-based installs tracking current repository (`1.3.0`):
47
46
 
48
47
  ```bash
49
48
  uv tool install git+https://github.com/xuanheng-tech/context-loader.git
@@ -54,8 +53,8 @@ The repository also retains `./project-context` as a direct development entry po
54
53
  ### Distributions
55
54
 
56
55
  The sole distribution is **`context-loader`**, containing the `context_loader` runtime
57
- and the `project-context` console script. Version `1.0.0` breaks the CLI name and Markdown
58
- heading contract. The JSON schema is unchanged. Terminal and any executor call this same
56
+ and the `project-context` console script. Version `1.0.0` broke the CLI name and Markdown
57
+ heading contract while leaving the then-current JSON schema unchanged. Terminal and any executor call this same
59
58
  entry point with explicit repository and focus arguments; no provider adapter or private
60
59
  session state is involved.
61
60
 
@@ -94,16 +93,17 @@ project-context --version
94
93
  project-context --repo /home/user/projects/example
95
94
  project-context --repo /home/user/projects/example --format markdown
96
95
  project-context --repo /home/user/projects/example --format json
96
+ project-context --repo /home/user/projects/example --format json-compact
97
97
  project-context --repo /home/user/projects/example \
98
98
  --focus "Authentication and session management" \
99
99
  --path auth/session.py
100
100
  ```
101
101
 
102
102
  `--format` defaults to `markdown`. In Markdown mode, `--repo` retains the 0.1.1 contract: it must be
103
- the absolute, canonical root of a non-bare Git working tree. In JSON mode, an absolute existing
104
- directory inside the working tree is accepted; symlinks are normalized and the discovered root is
105
- reported as `canonical_root`. Relative paths, non-Git directories, regular files, and bare
106
- repositories are rejected in both modes.
103
+ the absolute, canonical root of a non-bare Git working tree. In the `json` and `json-compact` modes,
104
+ an absolute existing directory inside the working tree is accepted; symlinks are normalized and the
105
+ discovered root is reported as `canonical_root`. Relative paths, non-Git directories, regular files,
106
+ and bare repositories are rejected in every mode.
107
107
 
108
108
  `--focus` and `--path` are optional, bounded selection signals for the root `AGENTS.md`. `--path`
109
109
  must be repository-relative. The collector does not retain either input in output or audit data.
@@ -126,15 +126,17 @@ The output has no generated timestamp, AI summary, architecture inference, or di
126
126
 
127
127
  ## JSON Output
128
128
 
129
- `--format json` writes exactly one compact UTF-8 JSON document plus one trailing newline to stdout.
130
- Keys are serialized in sorted order with `ensure_ascii=False`. The declared contract is:
129
+ `--format json` writes exactly one separator-compact UTF-8 JSON document plus one trailing newline
130
+ to stdout. Keys are serialized in sorted order with `ensure_ascii=False`. `--format json-compact`
131
+ uses the identical serialization; only the field set differs, as described under “Compact model
132
+ consumption”. The declared contract for `--format json` is:
131
133
 
132
134
  ```json
133
135
  {
134
- "schema_version": 1,
136
+ "schema_version": 3,
135
137
  "tool": {
136
138
  "name": "context-loader",
137
- "version": "1.1.0"
139
+ "version": "1.3.0"
138
140
  },
139
141
  "repository": {
140
142
  "requested_path": "/canonical/requested/path",
@@ -164,24 +166,78 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
164
166
  "context": "the same assembled Markdown context",
165
167
  "context_sha256": "sha256-hex",
166
168
  "statuses": [],
169
+ "nested_context": {
170
+ "files": ["docs/AGENTS.md"],
171
+ "list_truncated": false,
172
+ "scan_truncated": false
173
+ },
167
174
  "warnings": []
168
175
  }
169
176
  ```
170
177
 
171
178
  `context_sha256` hashes the UTF-8 bytes of `context`; each `content_sha256` does the same for that
172
- source's `content`. `sources` contains only file bodies that actually enter the final context, in
173
- assembly order, after the existing newline normalization and truncation rules. `scope` distinguishes
174
- `repository` from `global`; version 1.0.0's fixed root-file selection currently emits only
175
- `repository` sources and does not add any global-file discovery.
179
+ source's `content` body as rendered (after newline normalization, any `AGENTS.md` section selection
180
+ and any truncation marker) — never for the raw bytes of the file at `path`, which match only for
181
+ unselected, untruncated LF files. `sources` contains only file bodies that actually enter the final
182
+ context, in assembly order, after the existing newline normalization and truncation rules. `scope`
183
+ distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
184
+ only `repository` sources and does not add any global-file discovery.
176
185
 
177
- `statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown: skipped or absent sources, truncated sources, unreadable directory-tree entries, sections omitted under the global output budget, and truncated working-tree or declared-command listings. Each entry has stable `code`, `subject_kind`, and `subject` fields. `sources`, `context`, and `schema_version` remain unchanged; callers can ignore `statuses` safely.
186
+ `statuses` lists machine-readable collection and render conditions that previously appeared only inside `context` Markdown — plus the nested-context truncation flags, which exist only in machine form: skipped or absent sources, truncated sources, unreadable directory-tree entries, sections omitted under the global output budget, truncated working-tree or declared-command listings, and truncated nested-`AGENTS.md` presence reports. Each entry has stable `code`, `subject_kind`, and `subject` fields. The two `nested_agents_*` codes mirror the `nested_context` truncation booleans for consumers that branch only on `statuses`; the full `nested_context` object remains a first-class field, never folded into `statuses`. Callers can ignore `statuses` safely.
178
187
 
179
188
  The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
180
189
  contain heading, heading level, and fixed selection reasons; it never contains the original focus or
181
- target path. Existing source fields and schema version 1 remain unchanged.
182
-
183
- The JSON schema version and package version are independent: `schema_version` is currently the
184
- integer `1`, while `tool.version` is `1.1.0`. Callers must depend only on fields declared above.
190
+ target path. Existing per-source fields remain unchanged; the document's exact key
191
+ set (now including `nested_context`) is named by its `schema_version`, described below.
192
+
193
+ `nested_context` is always present and existence-only: `files` lists repository-relative paths of
194
+ non-directory `AGENTS.md` entries under subdirectories, found by a bounded scan that enumerates
195
+ directory entries, never opens or reads a candidate file and never traverses a symlink; it carries
196
+ no sizes, no mtimes and no contents. A symlinked (even dangling) `AGENTS.md` is listed, because its
197
+ existence comes from the directory entry itself; the scan never resolves what it points at. Any
198
+ `.git`, `.venv`, `venv`, `node_modules` or `site-packages` directory is skipped at every depth:
199
+ package-manager and interpreter-managed trees are not authored repository instructions.
200
+ `list_truncated` means more matching entries exist beyond the report caps (32 paths and 4 KiB of
201
+ path bytes); `scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, or
202
+ a directory or entry could not be read, so absence of a path is not proof of absence of the file.
203
+ Paths are sanitized with the same escaping the Markdown uses for repository-derived text, so the
204
+ document is always valid UTF-8. The corresponding
205
+ `nested_agents_list_truncated` and `nested_agents_scan_truncated` status entries mirror both flags.
206
+ Presence is not instruction: whether a nested file applies, and its text, remain the caller's
207
+ judgment; Markdown output is unchanged.
208
+
209
+ ### Compact model consumption (`--format json-compact`)
210
+
211
+ `--format json-compact` emits a `schema_version` 4 document: exactly the version-3 document with
212
+ `sources[*].content` omitted. Every other field — `context`, `context_sha256`, `statuses`,
213
+ `nested_context`, `warnings`, `tool`, `repository`, and each source's `ordinal`, `kind`, `scope`,
214
+ `path`, `content_sha256` and optional `selection` — is identical to `--format json` for the same
215
+ arguments.
216
+ `sources` is provenance and index metadata, not the place a body must be fetched from: every omitted
217
+ body is already inside `context` verbatim, so consumers should read `context` for rule text and
218
+ reopen a source file only when the rendered body was truncated or selected away — never to recover a
219
+ body that the document already carried.
220
+ The projection exists because the selected source bodies already occur verbatim inside `context`,
221
+ so carrying them again in `sources` duplicates a large share of the document bytes (measured up to
222
+ ~44% on real worktrees, scaling with how much of `context` those bodies occupy) for a model
223
+ consumer without adding information. Nothing is lost: each omitted body remains inside `context`,
224
+ `content_sha256` still fingerprints that rendered body, and `path` plus `canonical_root` locate the
225
+ underlying file for a bounded manual re-read (whose raw bytes may differ from the rendered body as
226
+ defined above). `schema_version` is an exact document selector, not an upgrade marker: version 4 is
227
+ a field-set projection of version 3, so consumers must branch on the value and must not apply
228
+ `>=`-superset reasoning. Introducing this compact format does
229
+ not otherwise change default Markdown bytes, exit codes or determinism.
230
+
231
+ One historical exception is acknowledged: releases 1.0.0 and 1.1.0 both emitted `schema_version` 1
232
+ although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward each emitted number
233
+ names exactly one key set, and the `nested_context` renumber below enforces that rule going forward.
234
+
235
+ The JSON schema version and package version are independent: this build emits `schema_version` `3`
236
+ for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.0`. Version 1
237
+ and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
238
+ `nested_context` changes the default document's key set, version 3 names the current full-document
239
+ key set and version 4 names the compact projection.
240
+ Callers must depend only on fields declared above.
185
241
  The document contains no generated time or random identifier, so unchanged input produces identical
186
242
  JSON bytes. On failure, stdout remains empty and stderr contains only a short diagnostic.
187
243
 
@@ -195,7 +251,9 @@ Resolving an instruction hierarchy stays with the calling agent harness. That in
195
251
  shared or user-level instruction file outside the repository, nested or scoped `AGENTS.md` files
196
252
  under subdirectories, and any include or import directive written inside an instruction file:
197
253
  such a directive is transported as literal text and is never followed. `--path` selects sections
198
- of the repository-root `AGENTS.md` only; it never changes which files are read.
254
+ of the repository-root `AGENTS.md` only; it never changes which files are read. The JSON-only
255
+ `nested_context` field reports the existence of nested `AGENTS.md` paths without reading them;
256
+ deciding whether any of them applies stays with the calling agent harness exactly as before.
199
257
 
200
258
  A successful run therefore proves that the bounded root context was collected and rendered. It
201
259
  does not prove that every instruction applicable to a task has been loaded.
@@ -236,6 +294,8 @@ scan that ends before EOF still reports the characters it could not select.
236
294
  - All entry-file bodies: 24 KiB
237
295
  - Declared commands: 8 KiB
238
296
  - Directory tree: 12 KiB, 300 entries, and depth 2
297
+ - Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, and at most 32 reported paths;
298
+ file contents are never read
239
299
  - Working-tree changes: 100 paths and 4 KiB
240
300
  - Recent commits: 8
241
301
  - Git subprocess output: 16 MiB (bounded while reading)
@@ -274,7 +334,8 @@ or candidate-file content.
274
334
 
275
335
  ## Not Included
276
336
 
277
- Version 1.0.0 does not provide AI summaries, project-type detection, nested `AGENTS.md` handling,
337
+ Version 1.3.0 does not provide AI summaries, project-type detection, loading or transport of
338
+ nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
278
339
  Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
279
340
  network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
280
341
 
@@ -288,9 +349,13 @@ just check
288
349
 
289
350
  ## Version maintenance
290
351
 
291
- The versions in `pyproject.toml` and `context_loader/__init__.py`, the matching `CHANGELOG.md`
292
- section, and required tests must change in the same release-preparation batch. `CHANGELOG.md` is the
293
- authoritative version-change record. Merging to `master` is not a release; formal publication
352
+ The versions in `pyproject.toml` and `context_loader/__init__.py`, the `tool_version` in
353
+ `tool_cli_contract.json`, the matching `CHANGELOG.md` section, required tests, and the README
354
+ current-stable declaration plus `context-loader==X.Y.Z`
355
+ install pins must change in the same release-preparation batch. `CHANGELOG.md` is the
356
+ authoritative version-change record. `scripts/release.py` rejects README stable/install
357
+ pins that disagree with the intended package version; historical changelog entries are
358
+ not part of that check. Merging to `master` is not a release; formal publication
294
359
  still requires a separately created and pushed tag.
295
360
 
296
361
  ## Release closure and retries
@@ -1,3 +1,3 @@
1
1
  """Project Context Loader."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.3.0"
@@ -20,13 +20,18 @@ from .collect import (
20
20
  AgentsSelectionAudit,
21
21
  AgentsSelectionInputError,
22
22
  CollectedFile,
23
+ NestedContextPresence,
23
24
  ProjectContext,
24
25
  collect_project_context,
25
26
  )
26
27
  from .git import ContextLoaderError, collect_repository
27
- from .render import render_markdown_with_details, rendered_source_contents
28
+ from .render import _display, render_markdown_with_details, rendered_source_contents
28
29
 
29
- JSON_SCHEMA_VERSION = 1
30
+ # Each value names one exact document shape and is never reused: 1 and 2 are the
31
+ # shapes published in release 1.2.0, so adding the nested_context object to both
32
+ # formats moved the default document to 3 and the compact projection to 4.
33
+ JSON_SCHEMA_VERSION = 3
34
+ COMPACT_JSON_SCHEMA_VERSION = 4
30
35
  TOOL_NAME = "context-loader"
31
36
 
32
37
  _STATUS_CODE_BY_MESSAGE = {
@@ -80,6 +85,7 @@ class ProjectContextResult:
80
85
  context_sha256: str
81
86
  warnings: tuple[str, ...]
82
87
  statuses: tuple[ContextStatus, ...]
88
+ nested_context: NestedContextPresence
83
89
 
84
90
 
85
91
  def _text_sha256(content: str) -> str:
@@ -184,11 +190,16 @@ def _build_statuses(
184
190
  for entry in project.directory_tree.entries:
185
191
  if entry.kind == "unreadable_directory":
186
192
  subject = entry.path if entry.path else "."
187
- statuses.append(_status("unreadable", "tree_entry", subject))
193
+ statuses.append(_status("unreadable", "tree_entry", _display(subject)))
188
194
 
189
195
  for title in omitted_sections:
190
196
  statuses.append(_status("section_omitted", "section", title))
191
197
 
198
+ if project.nested_context.list_truncated:
199
+ statuses.append(_status("nested_agents_list_truncated", "nested_context", "AGENTS.md"))
200
+ if project.nested_context.scan_truncated:
201
+ statuses.append(_status("nested_agents_scan_truncated", "nested_context", "AGENTS.md"))
202
+
192
203
  deduped: list[ContextStatus] = []
193
204
  seen: set[tuple[str, str, str]] = set()
194
205
  for status in statuses:
@@ -238,6 +249,7 @@ def load_project_context(
238
249
  context_sha256=hashlib.sha256(rendered.output).hexdigest(),
239
250
  warnings=(),
240
251
  statuses=statuses,
252
+ nested_context=project.nested_context,
241
253
  )
242
254
 
243
255
 
@@ -272,24 +284,30 @@ def _status_document(status: ContextStatus) -> dict[str, object]:
272
284
  }
273
285
 
274
286
 
275
- def _source_document(source: ProjectContextSource) -> dict[str, object]:
287
+ def _source_document(source: ProjectContextSource, *, include_content: bool) -> dict[str, object]:
276
288
  document: dict[str, object] = {
277
289
  "ordinal": source.ordinal,
278
290
  "kind": source.kind,
279
291
  "scope": source.scope,
280
292
  "path": os.fspath(source.path),
281
293
  "content_sha256": source.content_sha256,
282
- "content": source.content,
283
294
  }
295
+ if include_content:
296
+ document["content"] = source.content
284
297
  if source.selection is not None:
285
298
  document["selection"] = _selection_document(source.selection)
286
299
  return document
287
300
 
288
301
 
289
- def render_json(result: ProjectContextResult) -> bytes:
290
- """Serialize one result as stable UTF-8 JSON followed by exactly one newline."""
302
+ def render_json(result: ProjectContextResult, *, compact: bool = False) -> bytes:
303
+ """Serialize one result as stable UTF-8 JSON followed by exactly one newline.
304
+
305
+ ``compact`` projects away the duplicated source bodies only at this
306
+ boundary: statuses and context were computed from the full model at load
307
+ time, so both documents agree on everything except ``sources[*].content``.
308
+ """
291
309
  document = {
292
- "schema_version": result.schema_version,
310
+ "schema_version": (COMPACT_JSON_SCHEMA_VERSION if compact else result.schema_version),
293
311
  "tool": {
294
312
  "name": result.tool.name,
295
313
  "version": result.tool.version,
@@ -298,8 +316,17 @@ def render_json(result: ProjectContextResult) -> bytes:
298
316
  "requested_path": os.fspath(result.repository.requested_path),
299
317
  "canonical_root": os.fspath(result.repository.canonical_root),
300
318
  },
301
- "sources": [_source_document(source) for source in result.sources],
319
+ "sources": [
320
+ _source_document(source, include_content=not compact) for source in result.sources
321
+ ],
302
322
  "statuses": [_status_document(status) for status in result.statuses],
323
+ "nested_context": {
324
+ # Filesystem names arrive undecoded (surrogate-escaped); sanitize exactly like
325
+ # tree/change paths so no raw byte can reach the UTF-8 document.
326
+ "files": [_display(path) for path in result.nested_context.files],
327
+ "list_truncated": result.nested_context.list_truncated,
328
+ "scan_truncated": result.nested_context.scan_truncated,
329
+ },
303
330
  "context": result.context,
304
331
  "context_sha256": result.context_sha256,
305
332
  "warnings": list(result.warnings),
@@ -36,9 +36,12 @@ def _parser() -> argparse.ArgumentParser:
36
36
  )
37
37
  parser.add_argument(
38
38
  "--format",
39
- choices=("markdown", "json"),
39
+ choices=("markdown", "json", "json-compact"),
40
40
  default="markdown",
41
- help="output format (default: markdown)",
41
+ help=(
42
+ "output format (default: markdown); json-compact omits the "
43
+ "duplicated source bodies (see README, Compact model consumption)"
44
+ ),
42
45
  )
43
46
  return parser
44
47
 
@@ -52,11 +55,12 @@ def main(argv: Sequence[str] | None = None) -> int:
52
55
  focus=arguments.focus,
53
56
  path=arguments.target_path,
54
57
  )
55
- output = (
56
- result.context.encode("utf-8")
57
- if arguments.format == "markdown"
58
- else render_json(result)
59
- )
58
+ if arguments.format == "markdown":
59
+ output = result.context.encode("utf-8")
60
+ elif arguments.format == "json":
61
+ output = render_json(result)
62
+ else:
63
+ output = render_json(result, compact=True)
60
64
  except ContextLoaderError as exc:
61
65
  print(f"error: {exc}", file=sys.stderr)
62
66
  return exc.exit_code
@@ -26,6 +26,13 @@ DECLARED_COMMANDS_LIMIT_BYTES = 8 * 1024
26
26
  DIRECTORY_TREE_LIMIT_BYTES = 12 * 1024
27
27
  DIRECTORY_TREE_MAX_ITEMS = 300
28
28
  DIRECTORY_TREE_MAX_DEPTH = 2
29
+ NESTED_AGENTS_MAX_DEPTH = 4
30
+ NESTED_AGENTS_MAX_DIRECTORIES = 2_000
31
+ NESTED_AGENTS_MAX_FILES = 32
32
+ NESTED_AGENTS_MAX_LIST_BYTES = 4 * 1024
33
+ NESTED_AGENTS_EXCLUDED_DIRECTORIES = frozenset(
34
+ {".git", ".venv", "node_modules", "site-packages", "venv"}
35
+ )
29
36
  FILE_SCAN_LIMIT_BYTES = 16 * 1024 * 1024
30
37
  MAX_FILE_SCAN_BYTES = FILE_SCAN_LIMIT_BYTES
31
38
  TRUNCATION_MARKER = "… truncated by context-loader …"
@@ -190,6 +197,15 @@ class DirectoryTree:
190
197
  truncated: bool = False
191
198
 
192
199
 
200
+ @dataclass(frozen=True, slots=True)
201
+ class NestedContextPresence:
202
+ """Existence-only index of nested AGENTS.md files; their contents are never read."""
203
+
204
+ files: tuple[str, ...]
205
+ list_truncated: bool = False
206
+ scan_truncated: bool = False
207
+
208
+
193
209
  @dataclass(frozen=True, slots=True)
194
210
  class ProjectContext:
195
211
  instructions: CollectedFile
@@ -197,6 +213,7 @@ class ProjectContext:
197
213
  entry_files: tuple[CollectedFile, ...]
198
214
  commands: tuple[DeclaredCommand, ...]
199
215
  directory_tree: DirectoryTree
216
+ nested_context: NestedContextPresence
200
217
 
201
218
 
202
219
  @dataclass(frozen=True, slots=True)
@@ -975,6 +992,82 @@ def _collect_directory_tree(root: Path) -> DirectoryTree:
975
992
  return DirectoryTree(tuple(collected), truncated)
976
993
 
977
994
 
995
+ def collect_nested_agents_presence(repository: Path) -> NestedContextPresence:
996
+ """List nested AGENTS.md paths under a bounded, contents-blind directory scan.
997
+
998
+ The scan reads directory entries only: it never opens a candidate file, never
999
+ traverses a symlink, and reports truncation honestly so presence claims stay
1000
+ auditable. Any non-directory entry named AGENTS.md is listed, including
1001
+ symlinked ones, because existence comes from enumeration alone; entry types
1002
+ are never resolved beyond directory-vs-not, and contents remain the caller's
1003
+ responsibility to read.
1004
+ """
1005
+ files: list[str] = []
1006
+ reported_bytes = 0
1007
+ state = {"list_truncated": False, "scan_truncated": False, "directories": 0}
1008
+
1009
+ def walk(directory_descriptor: int, prefix: str, depth: int) -> None:
1010
+ nonlocal reported_bytes
1011
+ if depth > NESTED_AGENTS_MAX_DEPTH or state["directories"] >= NESTED_AGENTS_MAX_DIRECTORIES:
1012
+ state["scan_truncated"] = True
1013
+ return
1014
+ state["directories"] += 1
1015
+ try:
1016
+ with os.scandir(directory_descriptor) as iterator:
1017
+ entries = sorted(iterator, key=lambda entry: entry.name)
1018
+ except OSError:
1019
+ state["scan_truncated"] = True
1020
+ return
1021
+ subdirectories: list[str] = []
1022
+ for entry in entries:
1023
+ try:
1024
+ is_directory = entry.is_dir(follow_symlinks=False)
1025
+ except OSError:
1026
+ state["scan_truncated"] = True
1027
+ continue
1028
+ path = f"{prefix}/{entry.name}" if prefix else entry.name
1029
+ if is_directory:
1030
+ if entry.name not in NESTED_AGENTS_EXCLUDED_DIRECTORIES:
1031
+ subdirectories.append(path)
1032
+ continue
1033
+ if entry.name != "AGENTS.md" or not prefix:
1034
+ continue
1035
+ if len(files) >= NESTED_AGENTS_MAX_FILES:
1036
+ state["list_truncated"] = True
1037
+ continue
1038
+ name_bytes = len(path.encode("utf-8", errors="surrogateescape"))
1039
+ name_bytes += 1 if files else 0
1040
+ if reported_bytes + name_bytes > NESTED_AGENTS_MAX_LIST_BYTES:
1041
+ state["list_truncated"] = True
1042
+ continue
1043
+ reported_bytes += name_bytes
1044
+ files.append(path)
1045
+ for path in subdirectories:
1046
+ flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
1047
+ try:
1048
+ child_descriptor = os.open(
1049
+ path.rsplit("/", 1)[-1], flags, dir_fd=directory_descriptor
1050
+ )
1051
+ except OSError:
1052
+ state["scan_truncated"] = True
1053
+ continue
1054
+ try:
1055
+ walk(child_descriptor, path, depth + 1)
1056
+ finally:
1057
+ os.close(child_descriptor)
1058
+
1059
+ flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)
1060
+ try:
1061
+ root_descriptor = os.open(repository, flags)
1062
+ except OSError:
1063
+ return NestedContextPresence((), False, True)
1064
+ try:
1065
+ walk(root_descriptor, "", 0)
1066
+ finally:
1067
+ os.close(root_descriptor)
1068
+ return NestedContextPresence(tuple(files), state["list_truncated"], state["scan_truncated"])
1069
+
1070
+
978
1071
  def collect_project_context(
979
1072
  repository: Path,
980
1073
  *,
@@ -1001,4 +1094,5 @@ def collect_project_context(
1001
1094
  entry_files=entry_files,
1002
1095
  commands=_collect_commands(entry_files),
1003
1096
  directory_tree=_collect_directory_tree(repository),
1097
+ nested_context=collect_nested_agents_presence(repository),
1004
1098
  )
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "context-loader"
7
- version = "1.1.0"
7
+ version = "1.3.0"
8
8
  description = "Render deterministic local Git context for any executor"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12,<3.13"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "context-loader"
7
- version = "1.1.0"
7
+ version = "1.3.0"
8
8
  description = "Render deterministic local Git context for any executor"
9
9
  license = { file = "LICENSE" }
10
10
  readme = "README.md"
@@ -15,6 +15,7 @@
15
15
  "repo_is_absolute_git_worktree",
16
16
  "markdown_requires_canonical_root",
17
17
  "json_accepts_path_inside_worktree",
18
+ "json_compact_accepts_path_inside_worktree",
18
19
  "target_path_is_repository_relative"
19
20
  ],
20
21
  "result_statuses": [
@@ -24,11 +25,12 @@
24
25
  ]
25
26
  }
26
27
  ],
27
- "contract_version": 2,
28
+ "contract_version": 3,
28
29
  "flags": {
29
30
  "format": [
30
31
  "markdown",
31
- "json"
32
+ "json",
33
+ "json-compact"
32
34
  ],
33
35
  "output": "stdout",
34
36
  "stderr": "bounded_diagnostic_only"
@@ -44,5 +46,5 @@
44
46
  ],
45
47
  "schema_version": 1,
46
48
  "tool_name": "context-loader",
47
- "tool_version": "1.1.0"
49
+ "tool_version": "1.3.0"
48
50
  }
File without changes