context-loader 1.3.1__tar.gz → 1.3.2__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.3.1
3
+ Version: 1.3.2
4
4
  Summary: Render deterministic local Git context for any executor
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -219,14 +219,14 @@ Python standard library.
219
219
 
220
220
  ## Open-source quick start
221
221
 
222
- Current stable release: **1.3.1**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
222
+ Current stable release: **1.3.2**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
223
223
 
224
224
  ```bash
225
- uv tool install 'context-loader==1.3.1'
225
+ uv tool install 'context-loader==1.3.2'
226
226
  ```
227
227
 
228
228
  ```bash
229
- pip install 'context-loader==1.3.1'
229
+ pip install 'context-loader==1.3.2'
230
230
  ```
231
231
 
232
232
  ```bash
@@ -239,21 +239,21 @@ project-context --repo /path/to/repo --format json
239
239
 
240
240
  ## Install
241
241
 
242
- Source version: `1.3.1`. Install its matching published release or an exact source commit.
242
+ Source version: `1.3.2`. Install its matching published release or an exact source commit.
243
243
 
244
244
  Install via `uv`:
245
245
 
246
246
  ```bash
247
- uv tool install 'context-loader==1.3.1'
247
+ uv tool install 'context-loader==1.3.2'
248
248
  ```
249
249
 
250
250
  Install via `pip`:
251
251
 
252
252
  ```bash
253
- pip install 'context-loader==1.3.1'
253
+ pip install 'context-loader==1.3.2'
254
254
  ```
255
255
 
256
- For development or source-based installs tracking current repository (`1.3.1`):
256
+ For development or source-based installs tracking current repository (`1.3.2`):
257
257
 
258
258
  ```bash
259
259
  uv tool install git+https://github.com/xuanheng-tech/context-loader.git
@@ -319,6 +319,8 @@ and bare repositories are rejected in every mode.
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.
321
321
  Calls that omit both options remain valid and use the conservative fallback described below.
322
+ Each value option (`--repo`, `--focus`, `--path`, `--format`) may be supplied once;
323
+ duplicates are rejected before collection rather than silently overriding an earlier value.
322
324
 
323
325
  ## Markdown Output
324
326
 
@@ -348,7 +350,7 @@ set differs, as described under “Compact model consumption”. The declared co
348
350
  "schema_version": 3,
349
351
  "tool": {
350
352
  "name": "context-loader",
351
- "version": "1.3.1"
353
+ "version": "1.3.2"
352
354
  },
353
355
  "repository": {
354
356
  "requested_path": "/canonical/requested/path",
@@ -395,12 +397,12 @@ context, in assembly order, after the existing newline normalization and truncat
395
397
  distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
396
398
  only `repository` sources and does not add any global-file discovery.
397
399
 
398
- `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.
400
+ `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, directory-tree entries whose directory holds more entries than one enumeration examines, sections omitted under the global output budget, truncated working-tree, declared-command or directory-tree listings, and truncated nested-`AGENTS.md` presence reports. A skipped or absent source reports the code of the condition the collector observed (`not_present`, `skipped_symlink`, `skipped_not_regular`, `skipped_encoding`, `skipped_unreadable`); the sentence in `context` is rendered from that same condition, so wording and code cannot drift apart. 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.
399
401
 
400
402
  The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
401
403
  contain heading, heading level, and fixed selection reasons; it never contains the original focus or
402
404
  target path. Existing per-source fields remain unchanged; the document's exact key
403
- set (now including `nested_context`) is named by its `schema_version`, described below.
405
+ set (which includes `nested_context`) is named by its `schema_version`, described below.
404
406
 
405
407
  `nested_context` is always present and existence-only: `files` lists repository-relative paths of
406
408
  non-directory `AGENTS.md` entries under subdirectories, found by a bounded scan that enumerates
@@ -414,8 +416,9 @@ budget metered on the emitted array itself — each path's escaped, JSON-seriali
414
416
  separator, with the array's brackets charged to the first entry — so the emitted array never costs
415
417
  more than the budget it declares. Because quoting is counted, an array already near the cap can
416
418
  report one path fewer than an older release did, and then says so through `list_truncated`.
417
- `scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, or a directory
418
- or entry could not be read, so absence of a path is not proof of absence of the file. Nested paths
419
+ `scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, a directory
420
+ held more entries than one enumeration examines (1,024), or a directory or entry could not be
421
+ read, so absence of a path is not proof of absence of the file. Nested paths
419
422
  are sanitized with the same escaping the Markdown uses for repository-derived text, so a nested name
420
423
  the filesystem could not decode never breaks the document;
421
424
  see “Limits” for the root paths that are emitted verbatim. The corresponding
@@ -447,10 +450,10 @@ not otherwise change default Markdown bytes, exit codes or determinism.
447
450
 
448
451
  One historical exception is acknowledged: releases 1.0.0 and 1.1.0 both emitted `schema_version` 1
449
452
  although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward each emitted number
450
- names exactly one key set, and the `nested_context` renumber below enforces that rule going forward.
453
+ names exactly one key set, and a new number is issued whenever a key set would otherwise be reused.
451
454
 
452
455
  The JSON schema version and package version are independent: this build emits `schema_version` `3`
453
- for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.1`. Version 1
456
+ for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.2`. Version 1
454
457
  and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
455
458
  `nested_context` changes the default document's key set, version 3 names the current full-document
456
459
  key set and version 4 names the compact projection.
@@ -526,9 +529,40 @@ bullet is a per-component, read or capture bound, as each one states:
526
529
  - Each entry file: 8 KiB
527
530
  - All entry-file bodies: 24 KiB
528
531
  - Declared commands: 8 KiB
529
- - Directory tree: 12 KiB, 300 entries, and depth 2
530
- - Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, at most 32 reported paths and
531
- a 4 KiB budget metered on the emitted array; file contents are never read
532
+ - Directory tree: 12 KiB of listing body, 300 entries, and depth 2. Each directory is
533
+ enumerated up to 512 entries, keeping the alphabetically first names so the listing never
534
+ depends on operating system order. A directory that holds more is reported by a single
535
+ `Listing incomplete:` line stating the total, the limit and up to three names, and by at most
536
+ eight `directory_listing_incomplete` status entries naming individual directories plus one
537
+ aggregate entry giving the totals when more directories were capped than are named: beyond the
538
+ retained examples a capped directory is counted but not individually named, and no listing
539
+ claims completeness it does not have. That total covers the capped directories the listing
540
+ reached: a directory is reached only when its parent offered it inside the parent's own
541
+ alphabetical prefix and the entry budget kept it, so capped siblings displaced by the budget are
542
+ disclosed as their parent's truncation rather than as separate numbers.
543
+ Three limits act in sequence and a name survives only if all three leave room. The 512-entry
544
+ enumeration limit decides which names a directory offers at all; above it only an alphabetical
545
+ prefix is ever seen. The 300-entry item budget is then shared between a directory's
546
+ subdirectories and its non-directories: each may take up to half of what remains before the other
547
+ claims the surplus, which stops alphabetically-earlier directories from evicting every file, but
548
+ it is a share rather than a floor -- with a single slot left the non-directories take it. The 12
549
+ KiB listing budget then decides which kept names are rendered. Descent
550
+ happens after a directory's own entries, so deep content cannot displace top-level names, but
551
+ the tool promises no specific file: a root `README.md` is listed only when all three limits leave
552
+ room for it -- the enumeration offered it, the entry budget still has a slot, and the byte budget
553
+ still has room -- and each of those three can fail on its own. Being offered is not the same as
554
+ an uncapped root: the 512-entry cut drops only the names that sort after its retained prefix, so
555
+ an early-sorting `README.md` is still listed from a capped root while a late-sorting one never
556
+ reaches the listing. Whenever one of the three bites, the listing says so through the
557
+ note, the marker, or a `truncated` status instead of presenting itself as complete.
558
+ The `Listing incomplete:` line is charged to the 12 KiB listing budget before the body is cut,
559
+ so the claim can never inflate the section; its cost is paid in listing bytes, so a long note
560
+ removes that many bytes of entries, and the `truncated` status plus the marker inside the
561
+ fence say so. The heading and the code-fence lines are not metered, so the whole Markdown
562
+ section can exceed 12 KiB by that fixed few dozen bytes.
563
+ - Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, at most 32 reported paths, a
564
+ 4 KiB budget metered on the emitted array, and at most 1,024 entries examined per directory;
565
+ file contents are never read
532
566
  - Working-tree changes: 100 paths and 4 KiB
533
567
  - Recent commits: 8
534
568
  - JSON-only listings have no byte budget of their own: `statuses` subjects are escaped names bounded
@@ -571,7 +605,7 @@ or candidate-file content.
571
605
 
572
606
  ## Not Included
573
607
 
574
- Version 1.3.1 does not provide AI summaries, project-type detection, loading or transport of
608
+ Version 1.3.2 does not provide AI summaries, project-type detection, loading or transport of
575
609
  nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
576
610
  Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
577
611
  network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
@@ -609,6 +643,7 @@ PyPI file bytes, metadata and SHA-256, plus the publisher/tag/commit claims in P
609
643
  provenance. This is an identity check, not an independent cryptographic Sigstore verifier.
610
644
 
611
645
  - Tag/version or expected-commit mismatch and `just check` failure stop before build/upload.
646
+ - Annotated tag headers must match the exact tag name and commit, without extra or duplicate headers.
612
647
  - The build refuses to run unless `dist/` is empty, and refuses any produced file set other than
613
648
  the current version's wheel and sdist. A leftover artifact can carry a release filename while
614
649
  holding different bytes, so it is never treated as the current release.
@@ -8,14 +8,14 @@ Python standard library.
8
8
 
9
9
  ## Open-source quick start
10
10
 
11
- Current stable release: **1.3.1**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
11
+ Current stable release: **1.3.2**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
12
12
 
13
13
  ```bash
14
- uv tool install 'context-loader==1.3.1'
14
+ uv tool install 'context-loader==1.3.2'
15
15
  ```
16
16
 
17
17
  ```bash
18
- pip install 'context-loader==1.3.1'
18
+ pip install 'context-loader==1.3.2'
19
19
  ```
20
20
 
21
21
  ```bash
@@ -28,21 +28,21 @@ project-context --repo /path/to/repo --format json
28
28
 
29
29
  ## Install
30
30
 
31
- Source version: `1.3.1`. Install its matching published release or an exact source commit.
31
+ Source version: `1.3.2`. Install its matching published release or an exact source commit.
32
32
 
33
33
  Install via `uv`:
34
34
 
35
35
  ```bash
36
- uv tool install 'context-loader==1.3.1'
36
+ uv tool install 'context-loader==1.3.2'
37
37
  ```
38
38
 
39
39
  Install via `pip`:
40
40
 
41
41
  ```bash
42
- pip install 'context-loader==1.3.1'
42
+ pip install 'context-loader==1.3.2'
43
43
  ```
44
44
 
45
- For development or source-based installs tracking current repository (`1.3.1`):
45
+ For development or source-based installs tracking current repository (`1.3.2`):
46
46
 
47
47
  ```bash
48
48
  uv tool install git+https://github.com/xuanheng-tech/context-loader.git
@@ -108,6 +108,8 @@ and bare repositories are rejected in every mode.
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.
110
110
  Calls that omit both options remain valid and use the conservative fallback described below.
111
+ Each value option (`--repo`, `--focus`, `--path`, `--format`) may be supplied once;
112
+ duplicates are rejected before collection rather than silently overriding an earlier value.
111
113
 
112
114
  ## Markdown Output
113
115
 
@@ -137,7 +139,7 @@ set differs, as described under “Compact model consumption”. The declared co
137
139
  "schema_version": 3,
138
140
  "tool": {
139
141
  "name": "context-loader",
140
- "version": "1.3.1"
142
+ "version": "1.3.2"
141
143
  },
142
144
  "repository": {
143
145
  "requested_path": "/canonical/requested/path",
@@ -184,12 +186,12 @@ context, in assembly order, after the existing newline normalization and truncat
184
186
  distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
185
187
  only `repository` sources and does not add any global-file discovery.
186
188
 
187
- `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.
189
+ `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, directory-tree entries whose directory holds more entries than one enumeration examines, sections omitted under the global output budget, truncated working-tree, declared-command or directory-tree listings, and truncated nested-`AGENTS.md` presence reports. A skipped or absent source reports the code of the condition the collector observed (`not_present`, `skipped_symlink`, `skipped_not_regular`, `skipped_encoding`, `skipped_unreadable`); the sentence in `context` is rendered from that same condition, so wording and code cannot drift apart. 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.
188
190
 
189
191
  The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
190
192
  contain heading, heading level, and fixed selection reasons; it never contains the original focus or
191
193
  target path. Existing per-source fields remain unchanged; the document's exact key
192
- set (now including `nested_context`) is named by its `schema_version`, described below.
194
+ set (which includes `nested_context`) is named by its `schema_version`, described below.
193
195
 
194
196
  `nested_context` is always present and existence-only: `files` lists repository-relative paths of
195
197
  non-directory `AGENTS.md` entries under subdirectories, found by a bounded scan that enumerates
@@ -203,8 +205,9 @@ budget metered on the emitted array itself — each path's escaped, JSON-seriali
203
205
  separator, with the array's brackets charged to the first entry — so the emitted array never costs
204
206
  more than the budget it declares. Because quoting is counted, an array already near the cap can
205
207
  report one path fewer than an older release did, and then says so through `list_truncated`.
206
- `scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, or a directory
207
- or entry could not be read, so absence of a path is not proof of absence of the file. Nested paths
208
+ `scan_truncated` means the depth (4) or directory-count (2,000) budget was reached, a directory
209
+ held more entries than one enumeration examines (1,024), or a directory or entry could not be
210
+ read, so absence of a path is not proof of absence of the file. Nested paths
208
211
  are sanitized with the same escaping the Markdown uses for repository-derived text, so a nested name
209
212
  the filesystem could not decode never breaks the document;
210
213
  see “Limits” for the root paths that are emitted verbatim. The corresponding
@@ -236,10 +239,10 @@ not otherwise change default Markdown bytes, exit codes or determinism.
236
239
 
237
240
  One historical exception is acknowledged: releases 1.0.0 and 1.1.0 both emitted `schema_version` 1
238
241
  although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward each emitted number
239
- names exactly one key set, and the `nested_context` renumber below enforces that rule going forward.
242
+ names exactly one key set, and a new number is issued whenever a key set would otherwise be reused.
240
243
 
241
244
  The JSON schema version and package version are independent: this build emits `schema_version` `3`
242
- for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.1`. Version 1
245
+ for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.2`. Version 1
243
246
  and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
244
247
  `nested_context` changes the default document's key set, version 3 names the current full-document
245
248
  key set and version 4 names the compact projection.
@@ -315,9 +318,40 @@ bullet is a per-component, read or capture bound, as each one states:
315
318
  - Each entry file: 8 KiB
316
319
  - All entry-file bodies: 24 KiB
317
320
  - Declared commands: 8 KiB
318
- - Directory tree: 12 KiB, 300 entries, and depth 2
319
- - Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, at most 32 reported paths and
320
- a 4 KiB budget metered on the emitted array; file contents are never read
321
+ - Directory tree: 12 KiB of listing body, 300 entries, and depth 2. Each directory is
322
+ enumerated up to 512 entries, keeping the alphabetically first names so the listing never
323
+ depends on operating system order. A directory that holds more is reported by a single
324
+ `Listing incomplete:` line stating the total, the limit and up to three names, and by at most
325
+ eight `directory_listing_incomplete` status entries naming individual directories plus one
326
+ aggregate entry giving the totals when more directories were capped than are named: beyond the
327
+ retained examples a capped directory is counted but not individually named, and no listing
328
+ claims completeness it does not have. That total covers the capped directories the listing
329
+ reached: a directory is reached only when its parent offered it inside the parent's own
330
+ alphabetical prefix and the entry budget kept it, so capped siblings displaced by the budget are
331
+ disclosed as their parent's truncation rather than as separate numbers.
332
+ Three limits act in sequence and a name survives only if all three leave room. The 512-entry
333
+ enumeration limit decides which names a directory offers at all; above it only an alphabetical
334
+ prefix is ever seen. The 300-entry item budget is then shared between a directory's
335
+ subdirectories and its non-directories: each may take up to half of what remains before the other
336
+ claims the surplus, which stops alphabetically-earlier directories from evicting every file, but
337
+ it is a share rather than a floor -- with a single slot left the non-directories take it. The 12
338
+ KiB listing budget then decides which kept names are rendered. Descent
339
+ happens after a directory's own entries, so deep content cannot displace top-level names, but
340
+ the tool promises no specific file: a root `README.md` is listed only when all three limits leave
341
+ room for it -- the enumeration offered it, the entry budget still has a slot, and the byte budget
342
+ still has room -- and each of those three can fail on its own. Being offered is not the same as
343
+ an uncapped root: the 512-entry cut drops only the names that sort after its retained prefix, so
344
+ an early-sorting `README.md` is still listed from a capped root while a late-sorting one never
345
+ reaches the listing. Whenever one of the three bites, the listing says so through the
346
+ note, the marker, or a `truncated` status instead of presenting itself as complete.
347
+ The `Listing incomplete:` line is charged to the 12 KiB listing budget before the body is cut,
348
+ so the claim can never inflate the section; its cost is paid in listing bytes, so a long note
349
+ removes that many bytes of entries, and the `truncated` status plus the marker inside the
350
+ fence say so. The heading and the code-fence lines are not metered, so the whole Markdown
351
+ section can exceed 12 KiB by that fixed few dozen bytes.
352
+ - Nested `AGENTS.md` presence scan: depth 4, 2,000 directories, at most 32 reported paths, a
353
+ 4 KiB budget metered on the emitted array, and at most 1,024 entries examined per directory;
354
+ file contents are never read
321
355
  - Working-tree changes: 100 paths and 4 KiB
322
356
  - Recent commits: 8
323
357
  - JSON-only listings have no byte budget of their own: `statuses` subjects are escaped names bounded
@@ -360,7 +394,7 @@ or candidate-file content.
360
394
 
361
395
  ## Not Included
362
396
 
363
- Version 1.3.1 does not provide AI summaries, project-type detection, loading or transport of
397
+ Version 1.3.2 does not provide AI summaries, project-type detection, loading or transport of
364
398
  nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
365
399
  Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
366
400
  network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
@@ -398,6 +432,7 @@ PyPI file bytes, metadata and SHA-256, plus the publisher/tag/commit claims in P
398
432
  provenance. This is an identity check, not an independent cryptographic Sigstore verifier.
399
433
 
400
434
  - Tag/version or expected-commit mismatch and `just check` failure stop before build/upload.
435
+ - Annotated tag headers must match the exact tag name and commit, without extra or duplicate headers.
401
436
  - The build refuses to run unless `dist/` is empty, and refuses any produced file set other than
402
437
  the current version's wheel and sdist. A leftover artifact can carry a release filename while
403
438
  holding different bytes, so it is never treated as the current release.
@@ -1,3 +1,3 @@
1
1
  """Project Context Loader."""
2
2
 
3
- __version__ = "1.3.1"
3
+ __version__ = "1.3.2"
@@ -5,28 +5,29 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import json
7
7
  import os
8
- from dataclasses import dataclass
8
+ from dataclasses import dataclass, field
9
9
  from pathlib import Path
10
10
 
11
11
  from . import __version__
12
12
  from .collect import (
13
- NOT_PRESENT,
14
- SKIPPED_ENCODING,
15
- SKIPPED_NOT_REGULAR,
16
- SKIPPED_SYMLINK,
17
- SKIPPED_UNREADABLE,
18
- TRUNCATION_MARKER,
13
+ AgentsSelectionInputError,
14
+ collect_project_context,
15
+ )
16
+ from .filesystem import display_text
17
+ from .git import ContextLoaderError, collect_repository
18
+ from .model import (
19
19
  AgentsSectionAuditEntry,
20
20
  AgentsSelectionAudit,
21
- AgentsSelectionInputError,
21
+ Availability,
22
22
  CollectedFile,
23
23
  NestedContextPresence,
24
24
  ProjectContext,
25
- collect_project_context,
26
- display_text,
27
25
  )
28
- from .git import ContextLoaderError, collect_repository
29
- from .render import render_markdown_with_details, rendered_source_contents
26
+ from .render import (
27
+ RenderedSourceBody,
28
+ render_markdown_with_details,
29
+ rendered_source_contents,
30
+ )
30
31
 
31
32
  # Each value names one exact document shape and is never reused: 1 and 2 are the
32
33
  # shapes published in release 1.2.0, so adding the nested_context object to both
@@ -38,14 +39,6 @@ COMPACT_JSON_SCHEMA_VERSION = 4
38
39
  JSON_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024
39
40
  TOOL_NAME = "context-loader"
40
41
 
41
- _STATUS_CODE_BY_MESSAGE = {
42
- NOT_PRESENT: "not_present",
43
- SKIPPED_SYMLINK: "skipped_symlink",
44
- SKIPPED_NOT_REGULAR: "skipped_not_regular",
45
- SKIPPED_ENCODING: "skipped_encoding",
46
- SKIPPED_UNREADABLE: "skipped_unreadable",
47
- }
48
-
49
42
 
50
43
  @dataclass(frozen=True, slots=True)
51
44
  class ToolIdentity:
@@ -77,6 +70,10 @@ class ContextStatus:
77
70
  code: str
78
71
  subject_kind: str
79
72
  subject: str
73
+ # The raw fact behind the displayed subject. Display escaping is not injective, so
74
+ # deduplication must key on this and never on `subject`, or two different directories
75
+ # could collapse into one claim. It is not serialized.
76
+ identity: str = field(default="", compare=False)
80
77
 
81
78
 
82
79
  @dataclass(frozen=True, slots=True)
@@ -115,13 +112,11 @@ def _source_kind(name: str) -> str:
115
112
 
116
113
  def _sources(
117
114
  canonical_root: Path,
118
- project: ProjectContext,
119
- included_sections: tuple[str, ...],
115
+ rendered_bodies: tuple[RenderedSourceBody, ...],
120
116
  ) -> tuple[ProjectContextSource, ...]:
121
117
  sources: list[ProjectContextSource] = []
122
- for ordinal, (source, content) in enumerate(
123
- rendered_source_contents(project, included_sections)
124
- ):
118
+ for ordinal, rendered_body in enumerate(rendered_bodies):
119
+ source = rendered_body.source
125
120
  path = canonical_root / source.name
126
121
  sources.append(
127
122
  ProjectContextSource(
@@ -129,22 +124,23 @@ def _sources(
129
124
  kind=_source_kind(source.name),
130
125
  scope=source_scope_for_path(path, canonical_root),
131
126
  path=path,
132
- content_sha256=_text_sha256(content),
133
- content=content,
127
+ content_sha256=_text_sha256(rendered_body.body),
128
+ content=rendered_body.body,
134
129
  selection=source.selection,
135
130
  )
136
131
  )
137
132
  return tuple(sources)
138
133
 
139
134
 
140
- def _status(code: str, subject_kind: str, subject: str) -> ContextStatus:
141
- return ContextStatus(code=code, subject_kind=subject_kind, subject=subject)
135
+ def _status(code: str, subject_kind: str, subject: str, identity: str = "") -> ContextStatus:
136
+ return ContextStatus(
137
+ code=code, subject_kind=subject_kind, subject=subject, identity=identity or subject
138
+ )
142
139
 
143
140
 
144
141
  def _statuses_for_source(source: CollectedFile) -> list[ContextStatus]:
145
- if source.status is not None:
146
- code = _STATUS_CODE_BY_MESSAGE.get(source.status, "skipped_unreadable")
147
- return [_status(code, "source", source.name)]
142
+ if source.reason is not Availability.PRESENT:
143
+ return [_status(source.reason.value, "source", source.name)]
148
144
  statuses: list[ContextStatus] = []
149
145
  if source.truncated or (source.selection is not None and source.selection.truncated):
150
146
  statuses.append(_status("truncated", "source", source.name))
@@ -164,7 +160,8 @@ def _build_statuses(
164
160
  omitted_sections: tuple[str, ...],
165
161
  changes_truncated: bool,
166
162
  commands_truncated: bool,
167
- rendered_sources: tuple[ProjectContextSource, ...],
163
+ tree_truncated: bool,
164
+ rendered_bodies: tuple[RenderedSourceBody, ...],
168
165
  ) -> tuple[ContextStatus, ...]:
169
166
  statuses: list[ContextStatus] = []
170
167
  for source in (project.instructions, project.overview, *project.entry_files):
@@ -175,10 +172,10 @@ def _build_statuses(
175
172
  for status in statuses
176
173
  if status.subject_kind == "source" and status.code == "truncated"
177
174
  }
178
- for source in rendered_sources:
179
- if TRUNCATION_MARKER not in source.content:
175
+ for rendered_body in rendered_bodies:
176
+ if not rendered_body.truncated:
180
177
  continue
181
- name = source.path.name
178
+ name = rendered_body.source.name
182
179
  if name in truncated_sources:
183
180
  continue
184
181
  statuses.append(_status("truncated", "source", name))
@@ -189,13 +186,37 @@ def _build_statuses(
189
186
  if changes_truncated:
190
187
  statuses.append(_status("truncated", "changes", "Working Tree Changes"))
191
188
 
192
- if project.directory_tree.truncated:
189
+ if project.directory_tree.truncated or tree_truncated:
193
190
  statuses.append(_status("truncated", "tree", "Directory Tree"))
194
191
  for entry in project.directory_tree.entries:
195
192
  if entry.kind == "unreadable_directory":
196
193
  subject = entry.path if entry.path else "."
197
194
  statuses.append(_status("unreadable", "tree_entry", display_text(subject)))
198
-
195
+ tree = project.directory_tree
196
+ named = list(dict.fromkeys(tree.incomplete_directories))
197
+ for path in named:
198
+ subject = path if path else "."
199
+ statuses.append(
200
+ _status(
201
+ "directory_listing_incomplete",
202
+ "tree_entry",
203
+ display_text(subject),
204
+ identity=path,
205
+ )
206
+ )
207
+ unnamed = tree.incomplete_count - len(named)
208
+ if unnamed > 0:
209
+ stem = "directory" if unnamed == 1 else "directories"
210
+ statuses.append(
211
+ _status(
212
+ "directory_listing_incomplete",
213
+ "tree",
214
+ f"{tree.incomplete_count} directories exceeded the "
215
+ f"{tree.enumeration_limit}-entry per-directory enumeration limit; "
216
+ f"{unnamed} further {stem} not named individually",
217
+ identity=f"aggregate:{tree.incomplete_count}:{unnamed}",
218
+ )
219
+ )
199
220
  for title in omitted_sections:
200
221
  statuses.append(_status("section_omitted", "section", title))
201
222
 
@@ -207,7 +228,7 @@ def _build_statuses(
207
228
  deduped: list[ContextStatus] = []
208
229
  seen: set[tuple[str, str, str]] = set()
209
230
  for status in statuses:
210
- key = (status.code, status.subject_kind, status.subject)
231
+ key = (status.code, status.subject_kind, status.identity)
211
232
  if key in seen:
212
233
  continue
213
234
  seen.add(key)
@@ -233,13 +254,15 @@ def load_project_context(
233
254
  raise ContextLoaderError(str(exc), exit_code=2) from None
234
255
  rendered = render_markdown_with_details(state, project)
235
256
  context = rendered.output.decode("utf-8")
236
- sources = _sources(state.repository, project, rendered.included_sections)
257
+ rendered_bodies = rendered_source_contents(project, rendered.included_sections)
258
+ sources = _sources(state.repository, rendered_bodies)
237
259
  statuses = _build_statuses(
238
260
  project,
239
261
  omitted_sections=rendered.omitted_sections,
240
262
  changes_truncated=rendered.changes_truncated,
241
263
  commands_truncated=rendered.commands_truncated,
242
- rendered_sources=sources,
264
+ tree_truncated=rendered.tree_truncated,
265
+ rendered_bodies=rendered_bodies,
243
266
  )
244
267
  return ProjectContextResult(
245
268
  schema_version=JSON_SCHEMA_VERSION,
@@ -17,6 +17,21 @@ class SafeArgumentParser(argparse.ArgumentParser):
17
17
  raise ContextLoaderError("invalid command-line arguments", exit_code=2)
18
18
 
19
19
 
20
+ class _OnceAction(argparse.Action):
21
+ def __call__(
22
+ self,
23
+ parser: argparse.ArgumentParser,
24
+ namespace: argparse.Namespace,
25
+ values: str,
26
+ option_string: str | None = None,
27
+ ) -> None:
28
+ seen = f"_seen_{self.dest}"
29
+ if getattr(namespace, seen, False):
30
+ parser.error("option supplied more than once")
31
+ setattr(namespace, seen, True)
32
+ setattr(namespace, self.dest, values)
33
+
34
+
20
35
  def _parser() -> argparse.ArgumentParser:
21
36
  parser = SafeArgumentParser(
22
37
  prog="project-context",
@@ -24,20 +39,25 @@ def _parser() -> argparse.ArgumentParser:
24
39
  allow_abbrev=False,
25
40
  )
26
41
  parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
27
- parser.add_argument("--repo", required=True, help="absolute Git worktree path")
42
+ parser.add_argument(
43
+ "--repo", required=True, action=_OnceAction, help="absolute Git worktree path"
44
+ )
28
45
  parser.add_argument(
29
46
  "--focus",
47
+ action=_OnceAction,
30
48
  help="optional bounded task focus used for deterministic AGENTS section selection",
31
49
  )
32
50
  parser.add_argument(
33
51
  "--path",
34
52
  dest="target_path",
53
+ action=_OnceAction,
35
54
  help="optional repository-relative target path used for AGENTS section selection",
36
55
  )
37
56
  parser.add_argument(
38
57
  "--format",
39
58
  choices=("markdown", "json", "json-compact"),
40
59
  default="markdown",
60
+ action=_OnceAction,
41
61
  help=(
42
62
  "output format (default: markdown); json-compact omits the "
43
63
  "duplicated source bodies (see README, Compact model consumption)"