context-loader 1.0.0__tar.gz → 1.2.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.0.0
3
+ Version: 1.2.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.2.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.2.0'
227
226
  ```
228
227
 
229
228
  ```bash
230
- pip install context-loader
229
+ pip install 'context-loader==1.2.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.0.0`. Install its matching published release or an exact source commit.
242
+ Source version: `1.2.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.2.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.2.0'
255
254
  ```
256
255
 
257
- For development or source-based installs tracking current repository (`1.0.0`):
256
+ For development or source-based installs tracking current repository (`1.2.0`):
258
257
 
259
258
  ```bash
260
259
  uv tool install git+https://github.com/xuanheng-tech/context-loader.git
@@ -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
347
  "schema_version": 1,
346
348
  "tool": {
347
349
  "name": "context-loader",
348
- "version": "1.0.0"
350
+ "version": "1.2.0"
349
351
  },
350
352
  "repository": {
351
353
  "requested_path": "/canonical/requested/path",
@@ -374,22 +376,45 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
374
376
  ],
375
377
  "context": "the same assembled Markdown context",
376
378
  "context_sha256": "sha256-hex",
379
+ "statuses": [],
377
380
  "warnings": []
378
381
  }
379
382
  ```
380
383
 
381
384
  `context_sha256` hashes the UTF-8 bytes of `context`; each `content_sha256` does the same for that
382
- source's `content`. `sources` contains only file bodies that actually enter the final context, in
383
- assembly order, after the existing newline normalization and truncation rules. `scope` distinguishes
384
- `repository` from `global`; version 1.0.0's fixed root-file selection currently emits only
385
- `repository` sources and does not add any global-file discovery.
385
+ source's `content` body as rendered (after newline normalization, any `AGENTS.md` section selection
386
+ and any truncation marker) — never for the raw bytes of the file at `path`, which match only for
387
+ unselected, untruncated LF files. `sources` contains only file bodies that actually enter the final
388
+ context, in assembly order, after the existing newline normalization and truncation rules. `scope`
389
+ distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
390
+ only `repository` sources and does not add any global-file discovery.
391
+
392
+ `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.
386
393
 
387
394
  The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
388
395
  contain heading, heading level, and fixed selection reasons; it never contains the original focus or
389
396
  target path. Existing source fields and schema version 1 remain unchanged.
390
397
 
391
- The JSON schema version and package version are independent: `schema_version` is currently the
392
- integer `1`, while `tool.version` is `1.0.0`. Callers must depend only on fields declared above.
398
+ ### Compact model consumption (`--format json-compact`)
399
+
400
+ `--format json-compact` emits a `schema_version` 2 document: exactly the version-1 document with
401
+ `sources[*].content` omitted. Every other field — `context`, `context_sha256`, `statuses`,
402
+ `warnings`, `tool`, `repository`, and each source's `ordinal`, `kind`, `scope`, `path`,
403
+ `content_sha256` and optional `selection` — is identical to `--format json` for the same arguments.
404
+ The projection exists because the selected source bodies already occur verbatim inside `context`,
405
+ so carrying them again in `sources` duplicates a large share of the document bytes (measured up to
406
+ ~44% on real worktrees, scaling with how much of `context` those bodies occupy) for a model
407
+ consumer without adding information. Nothing is lost: each omitted body remains inside `context`,
408
+ `content_sha256` still fingerprints that rendered body, and `path` plus `canonical_root` locate the
409
+ underlying file for a bounded manual re-read (whose raw bytes may differ from the rendered body as
410
+ defined above). `schema_version` is an exact document selector, not an upgrade marker: version 2 is
411
+ a field-set projection of version 1, so consumers must branch on the value and must not apply
412
+ `>=`-superset reasoning. Default Markdown and `--format json` output bytes, exit codes and
413
+ determinism are unchanged.
414
+
415
+ The JSON schema version and package version are independent: `--format json` currently emits
416
+ `schema_version` `1` and `--format json-compact` emits `2`, while `tool.version` is `1.2.0`.
417
+ Callers must depend only on fields declared above.
393
418
  The document contains no generated time or random identifier, so unchanged input produces identical
394
419
  JSON bytes. On failure, stdout remains empty and stderr contains only a short diagnostic.
395
420
 
@@ -497,8 +522,11 @@ just check
497
522
  ## Version maintenance
498
523
 
499
524
  The versions in `pyproject.toml` and `context_loader/__init__.py`, the matching `CHANGELOG.md`
500
- section, and required tests must change in the same release-preparation batch. `CHANGELOG.md` is the
501
- authoritative version-change record. Merging to `master` is not a release; formal publication
525
+ section, required tests, and the README current-stable declaration plus `context-loader==X.Y.Z`
526
+ install pins must change in the same release-preparation batch. `CHANGELOG.md` is the
527
+ authoritative version-change record. `scripts/release.py` rejects README stable/install
528
+ pins that disagree with the intended package version; historical changelog entries are
529
+ not part of that check. Merging to `master` is not a release; formal publication
502
530
  still requires a separately created and pushed tag.
503
531
 
504
532
  ## 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.2.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.2.0'
16
15
  ```
17
16
 
18
17
  ```bash
19
- pip install context-loader
18
+ pip install 'context-loader==1.2.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.0.0`. Install its matching published release or an exact source commit.
31
+ Source version: `1.2.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.2.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.2.0'
44
43
  ```
45
44
 
46
- For development or source-based installs tracking current repository (`1.0.0`):
45
+ For development or source-based installs tracking current repository (`1.2.0`):
47
46
 
48
47
  ```bash
49
48
  uv tool install git+https://github.com/xuanheng-tech/context-loader.git
@@ -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
136
  "schema_version": 1,
135
137
  "tool": {
136
138
  "name": "context-loader",
137
- "version": "1.0.0"
139
+ "version": "1.2.0"
138
140
  },
139
141
  "repository": {
140
142
  "requested_path": "/canonical/requested/path",
@@ -163,22 +165,45 @@ Keys are serialized in sorted order with `ensure_ascii=False`. The declared cont
163
165
  ],
164
166
  "context": "the same assembled Markdown context",
165
167
  "context_sha256": "sha256-hex",
168
+ "statuses": [],
166
169
  "warnings": []
167
170
  }
168
171
  ```
169
172
 
170
173
  `context_sha256` hashes the UTF-8 bytes of `context`; each `content_sha256` does the same for that
171
- source's `content`. `sources` contains only file bodies that actually enter the final context, in
172
- assembly order, after the existing newline normalization and truncation rules. `scope` distinguishes
173
- `repository` from `global`; version 1.0.0's fixed root-file selection currently emits only
174
- `repository` sources and does not add any global-file discovery.
174
+ source's `content` body as rendered (after newline normalization, any `AGENTS.md` section selection
175
+ and any truncation marker) — never for the raw bytes of the file at `path`, which match only for
176
+ unselected, untruncated LF files. `sources` contains only file bodies that actually enter the final
177
+ context, in assembly order, after the existing newline normalization and truncation rules. `scope`
178
+ distinguishes `repository` from `global`; version 1.0.0's fixed root-file selection currently emits
179
+ only `repository` sources and does not add any global-file discovery.
180
+
181
+ `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.
175
182
 
176
183
  The optional `selection` object is present only on a rendered `AGENTS.md` source. Its section entries
177
184
  contain heading, heading level, and fixed selection reasons; it never contains the original focus or
178
185
  target path. Existing source fields and schema version 1 remain unchanged.
179
186
 
180
- The JSON schema version and package version are independent: `schema_version` is currently the
181
- integer `1`, while `tool.version` is `1.0.0`. Callers must depend only on fields declared above.
187
+ ### Compact model consumption (`--format json-compact`)
188
+
189
+ `--format json-compact` emits a `schema_version` 2 document: exactly the version-1 document with
190
+ `sources[*].content` omitted. Every other field — `context`, `context_sha256`, `statuses`,
191
+ `warnings`, `tool`, `repository`, and each source's `ordinal`, `kind`, `scope`, `path`,
192
+ `content_sha256` and optional `selection` — is identical to `--format json` for the same arguments.
193
+ The projection exists because the selected source bodies already occur verbatim inside `context`,
194
+ so carrying them again in `sources` duplicates a large share of the document bytes (measured up to
195
+ ~44% on real worktrees, scaling with how much of `context` those bodies occupy) for a model
196
+ consumer without adding information. Nothing is lost: each omitted body remains inside `context`,
197
+ `content_sha256` still fingerprints that rendered body, and `path` plus `canonical_root` locate the
198
+ underlying file for a bounded manual re-read (whose raw bytes may differ from the rendered body as
199
+ defined above). `schema_version` is an exact document selector, not an upgrade marker: version 2 is
200
+ a field-set projection of version 1, so consumers must branch on the value and must not apply
201
+ `>=`-superset reasoning. Default Markdown and `--format json` output bytes, exit codes and
202
+ determinism are unchanged.
203
+
204
+ The JSON schema version and package version are independent: `--format json` currently emits
205
+ `schema_version` `1` and `--format json-compact` emits `2`, while `tool.version` is `1.2.0`.
206
+ Callers must depend only on fields declared above.
182
207
  The document contains no generated time or random identifier, so unchanged input produces identical
183
208
  JSON bytes. On failure, stdout remains empty and stderr contains only a short diagnostic.
184
209
 
@@ -286,8 +311,11 @@ just check
286
311
  ## Version maintenance
287
312
 
288
313
  The versions in `pyproject.toml` and `context_loader/__init__.py`, the matching `CHANGELOG.md`
289
- section, and required tests must change in the same release-preparation batch. `CHANGELOG.md` is the
290
- authoritative version-change record. Merging to `master` is not a release; formal publication
314
+ section, required tests, and the README current-stable declaration plus `context-loader==X.Y.Z`
315
+ install pins must change in the same release-preparation batch. `CHANGELOG.md` is the
316
+ authoritative version-change record. `scripts/release.py` rejects README stable/install
317
+ pins that disagree with the intended package version; historical changelog entries are
318
+ not part of that check. Merging to `master` is not a release; formal publication
291
319
  still requires a separately created and pushed tag.
292
320
 
293
321
  ## Release closure and retries
@@ -1,3 +1,3 @@
1
1
  """Project Context Loader."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.2.0"
@@ -10,9 +10,16 @@ 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
19
  AgentsSectionAuditEntry,
14
20
  AgentsSelectionAudit,
15
21
  AgentsSelectionInputError,
22
+ CollectedFile,
16
23
  ProjectContext,
17
24
  collect_project_context,
18
25
  )
@@ -20,8 +27,17 @@ from .git import ContextLoaderError, collect_repository
20
27
  from .render import render_markdown_with_details, rendered_source_contents
21
28
 
22
29
  JSON_SCHEMA_VERSION = 1
30
+ COMPACT_JSON_SCHEMA_VERSION = 2
23
31
  TOOL_NAME = "context-loader"
24
32
 
33
+ _STATUS_CODE_BY_MESSAGE = {
34
+ NOT_PRESENT: "not_present",
35
+ SKIPPED_SYMLINK: "skipped_symlink",
36
+ SKIPPED_NOT_REGULAR: "skipped_not_regular",
37
+ SKIPPED_ENCODING: "skipped_encoding",
38
+ SKIPPED_UNREADABLE: "skipped_unreadable",
39
+ }
40
+
25
41
 
26
42
  @dataclass(frozen=True, slots=True)
27
43
  class ToolIdentity:
@@ -46,6 +62,15 @@ class ProjectContextSource:
46
62
  selection: AgentsSelectionAudit | None = None
47
63
 
48
64
 
65
+ @dataclass(frozen=True, slots=True)
66
+ class ContextStatus:
67
+ """One machine-readable skipped, omitted, truncated, or unreadable condition."""
68
+
69
+ code: str
70
+ subject_kind: str
71
+ subject: str
72
+
73
+
49
74
  @dataclass(frozen=True, slots=True)
50
75
  class ProjectContextResult:
51
76
  schema_version: int
@@ -55,6 +80,7 @@ class ProjectContextResult:
55
80
  context: str
56
81
  context_sha256: str
57
82
  warnings: tuple[str, ...]
83
+ statuses: tuple[ContextStatus, ...]
58
84
 
59
85
 
60
86
  def _text_sha256(content: str) -> str:
@@ -102,6 +128,79 @@ def _sources(
102
128
  return tuple(sources)
103
129
 
104
130
 
131
+ def _status(code: str, subject_kind: str, subject: str) -> ContextStatus:
132
+ return ContextStatus(code=code, subject_kind=subject_kind, subject=subject)
133
+
134
+
135
+ def _statuses_for_source(source: CollectedFile) -> list[ContextStatus]:
136
+ if source.status is not None:
137
+ code = _STATUS_CODE_BY_MESSAGE.get(source.status, "skipped_unreadable")
138
+ return [_status(code, "source", source.name)]
139
+ statuses: list[ContextStatus] = []
140
+ if source.truncated or (source.selection is not None and source.selection.truncated):
141
+ statuses.append(_status("truncated", "source", source.name))
142
+ if source.selection is not None:
143
+ if source.selection.parse_fallback:
144
+ statuses.append(_status("parse_fallback", "source", source.name))
145
+ if source.selection.source_scan_truncated:
146
+ statuses.append(_status("source_scan_truncated", "source", source.name))
147
+ if source.selection.index_truncated:
148
+ statuses.append(_status("index_truncated", "source", source.name))
149
+ return statuses
150
+
151
+
152
+ def _build_statuses(
153
+ project: ProjectContext,
154
+ *,
155
+ omitted_sections: tuple[str, ...],
156
+ changes_truncated: bool,
157
+ commands_truncated: bool,
158
+ rendered_sources: tuple[ProjectContextSource, ...],
159
+ ) -> tuple[ContextStatus, ...]:
160
+ statuses: list[ContextStatus] = []
161
+ for source in (project.instructions, project.overview, *project.entry_files):
162
+ statuses.extend(_statuses_for_source(source))
163
+
164
+ truncated_sources = {
165
+ status.subject
166
+ for status in statuses
167
+ if status.subject_kind == "source" and status.code == "truncated"
168
+ }
169
+ for source in rendered_sources:
170
+ if TRUNCATION_MARKER not in source.content:
171
+ continue
172
+ name = source.path.name
173
+ if name in truncated_sources:
174
+ continue
175
+ statuses.append(_status("truncated", "source", name))
176
+ truncated_sources.add(name)
177
+
178
+ if commands_truncated:
179
+ statuses.append(_status("truncated", "commands", "Declared Commands"))
180
+ if changes_truncated:
181
+ statuses.append(_status("truncated", "changes", "Working Tree Changes"))
182
+
183
+ if project.directory_tree.truncated:
184
+ statuses.append(_status("truncated", "tree", "Directory Tree"))
185
+ for entry in project.directory_tree.entries:
186
+ if entry.kind == "unreadable_directory":
187
+ subject = entry.path if entry.path else "."
188
+ statuses.append(_status("unreadable", "tree_entry", subject))
189
+
190
+ for title in omitted_sections:
191
+ statuses.append(_status("section_omitted", "section", title))
192
+
193
+ deduped: list[ContextStatus] = []
194
+ seen: set[tuple[str, str, str]] = set()
195
+ for status in statuses:
196
+ key = (status.code, status.subject_kind, status.subject)
197
+ if key in seen:
198
+ continue
199
+ seen.add(key)
200
+ deduped.append(status)
201
+ return tuple(deduped)
202
+
203
+
105
204
  def load_project_context(
106
205
  repo: str | os.PathLike[str],
107
206
  *,
@@ -120,6 +219,14 @@ def load_project_context(
120
219
  raise ContextLoaderError(str(exc), exit_code=2) from None
121
220
  rendered = render_markdown_with_details(state, project)
122
221
  context = rendered.output.decode("utf-8")
222
+ sources = _sources(state.repository, project, rendered.included_sections)
223
+ statuses = _build_statuses(
224
+ project,
225
+ omitted_sections=rendered.omitted_sections,
226
+ changes_truncated=rendered.changes_truncated,
227
+ commands_truncated=rendered.commands_truncated,
228
+ rendered_sources=sources,
229
+ )
123
230
  return ProjectContextResult(
124
231
  schema_version=JSON_SCHEMA_VERSION,
125
232
  tool=ToolIdentity(name=TOOL_NAME, version=__version__),
@@ -127,10 +234,11 @@ def load_project_context(
127
234
  requested_path=location.requested_path,
128
235
  canonical_root=location.canonical_root,
129
236
  ),
130
- sources=_sources(state.repository, project, rendered.included_sections),
237
+ sources=sources,
131
238
  context=context,
132
239
  context_sha256=hashlib.sha256(rendered.output).hexdigest(),
133
240
  warnings=(),
241
+ statuses=statuses,
134
242
  )
135
243
 
136
244
 
@@ -157,24 +265,38 @@ def _selection_document(selection: AgentsSelectionAudit) -> dict[str, object]:
157
265
  }
158
266
 
159
267
 
160
- def _source_document(source: ProjectContextSource) -> dict[str, object]:
268
+ def _status_document(status: ContextStatus) -> dict[str, object]:
269
+ return {
270
+ "code": status.code,
271
+ "subject": status.subject,
272
+ "subject_kind": status.subject_kind,
273
+ }
274
+
275
+
276
+ def _source_document(source: ProjectContextSource, *, include_content: bool) -> dict[str, object]:
161
277
  document: dict[str, object] = {
162
278
  "ordinal": source.ordinal,
163
279
  "kind": source.kind,
164
280
  "scope": source.scope,
165
281
  "path": os.fspath(source.path),
166
282
  "content_sha256": source.content_sha256,
167
- "content": source.content,
168
283
  }
284
+ if include_content:
285
+ document["content"] = source.content
169
286
  if source.selection is not None:
170
287
  document["selection"] = _selection_document(source.selection)
171
288
  return document
172
289
 
173
290
 
174
- def render_json(result: ProjectContextResult) -> bytes:
175
- """Serialize one result as stable UTF-8 JSON followed by exactly one newline."""
291
+ def render_json(result: ProjectContextResult, *, compact: bool = False) -> bytes:
292
+ """Serialize one result as stable UTF-8 JSON followed by exactly one newline.
293
+
294
+ ``compact`` projects away the duplicated source bodies only at this
295
+ boundary: statuses and context were computed from the full model at load
296
+ time, so both documents agree on everything except ``sources[*].content``.
297
+ """
176
298
  document = {
177
- "schema_version": result.schema_version,
299
+ "schema_version": (COMPACT_JSON_SCHEMA_VERSION if compact else result.schema_version),
178
300
  "tool": {
179
301
  "name": result.tool.name,
180
302
  "version": result.tool.version,
@@ -183,7 +305,10 @@ def render_json(result: ProjectContextResult) -> bytes:
183
305
  "requested_path": os.fspath(result.repository.requested_path),
184
306
  "canonical_root": os.fspath(result.repository.canonical_root),
185
307
  },
186
- "sources": [_source_document(source) for source in result.sources],
308
+ "sources": [
309
+ _source_document(source, include_content=not compact) for source in result.sources
310
+ ],
311
+ "statuses": [_status_document(status) for status in result.statuses],
187
312
  "context": result.context,
188
313
  "context_sha256": result.context_sha256,
189
314
  "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 emits schema "
43
+ "version 2 without duplicated source bodies"
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
@@ -34,6 +34,9 @@ GLOBAL_OMISSION = "Omitted: global output limit reached."
34
34
  class MarkdownRender:
35
35
  output: bytes
36
36
  included_sections: tuple[str, ...]
37
+ omitted_sections: tuple[str, ...]
38
+ changes_truncated: bool
39
+ commands_truncated: bool
37
40
 
38
41
 
39
42
  def _display(value: str) -> str:
@@ -173,10 +176,12 @@ def _command_line(command: DeclaredCommand) -> str:
173
176
  return f"- `{invocation}` → `{_display(command.target)}`"
174
177
 
175
178
 
176
- def _bounded_lines(lines: list[str], limit: int, *, already_truncated: bool = False) -> str:
179
+ def _bounded_lines(
180
+ lines: list[str], limit: int, *, already_truncated: bool = False
181
+ ) -> tuple[str, bool]:
177
182
  complete = "\n".join(lines)
178
183
  if not already_truncated and len(complete.encode()) <= limit:
179
- return complete
184
+ return complete, False
180
185
  marker_size = len(TRUNCATION_MARKER.encode())
181
186
  budget = max(0, limit - marker_size - 1)
182
187
  selected: list[str] = []
@@ -188,20 +193,15 @@ def _bounded_lines(lines: list[str], limit: int, *, already_truncated: bool = Fa
188
193
  selected.append(line)
189
194
  used += size
190
195
  selected.append(TRUNCATION_MARKER)
191
- return "\n".join(selected)
196
+ return "\n".join(selected), True
192
197
 
193
198
 
194
- def _render_commands(commands: tuple[DeclaredCommand, ...]) -> str:
199
+ def _render_commands(commands: tuple[DeclaredCommand, ...]) -> tuple[str, bool]:
195
200
  lines = [_command_line(command) for command in commands]
196
201
  if not lines:
197
202
  lines.append("No supported command declarations found.")
198
- return "\n".join(
199
- (
200
- "## Declared Commands",
201
- "",
202
- _bounded_lines(lines, DECLARED_COMMANDS_LIMIT_BYTES),
203
- )
204
- )
203
+ body, truncated = _bounded_lines(lines, DECLARED_COMMANDS_LIMIT_BYTES)
204
+ return "\n".join(("## Declared Commands", "", body)), truncated
205
205
 
206
206
 
207
207
  def _entry_body(source: CollectedFile, remaining: int) -> tuple[str | None, int]:
@@ -276,7 +276,7 @@ def _render_directory_tree(tree: DirectoryTree) -> str:
276
276
  lines = [_tree_line(entry) for entry in tree.entries]
277
277
  if not lines:
278
278
  lines.append("No entries.")
279
- body = _bounded_lines(
279
+ body, _truncated = _bounded_lines(
280
280
  lines,
281
281
  DIRECTORY_TREE_LIMIT_BYTES,
282
282
  already_truncated=tree.truncated,
@@ -298,6 +298,8 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
298
298
  f"- Repository: `{_display(os.fspath(state.repository))}`",
299
299
  )
300
300
  )
301
+ commands_section, commands_truncated = _render_commands(project.commands)
302
+ changes_truncated = _change_lines(state)[1]
301
303
  sections: list[tuple[str, str | None]] = [
302
304
  ("Git State", _render_git_state(state)),
303
305
  (
@@ -312,7 +314,7 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
312
314
  "Project Overview",
313
315
  _render_source_section("Project Overview", project.overview, README_LIMIT_BYTES),
314
316
  ),
315
- ("Declared Commands", _render_commands(project.commands)),
317
+ ("Declared Commands", commands_section),
316
318
  ("Project Entry Files", _render_entry_files(project.entry_files)),
317
319
  ("Recent Commits", _render_recent_commits(state.commits)),
318
320
  ("Directory Tree", _render_directory_tree(project.directory_tree)),
@@ -320,6 +322,7 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
320
322
 
321
323
  rendered_parts = [header.encode()]
322
324
  included_sections: list[str] = []
325
+ omitted_sections: list[str] = []
323
326
  omission_started = False
324
327
  for index, (title, full_section) in enumerate(sections):
325
328
  omission = _omitted_section(title).encode()
@@ -330,6 +333,7 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
330
333
  if omission_started or full_section is None:
331
334
  selected = omission
332
335
  omission_started = True
336
+ omitted_sections.append(title)
333
337
  else:
334
338
  candidate = full_section.encode()
335
339
  tentative = b"\n\n".join((*rendered_parts, candidate, *future_omissions)) + b"\n"
@@ -339,12 +343,19 @@ def render_markdown_with_details(state: RepositoryState, project: ProjectContext
339
343
  else:
340
344
  selected = omission
341
345
  omission_started = True
346
+ omitted_sections.append(title)
342
347
  rendered_parts.append(selected)
343
348
 
344
349
  output = b"\n\n".join(rendered_parts) + b"\n"
345
350
  if len(output) > GLOBAL_OUTPUT_LIMIT_BYTES:
346
351
  raise RuntimeError("global context output limit could not be satisfied")
347
- return MarkdownRender(output=output, included_sections=tuple(included_sections))
352
+ return MarkdownRender(
353
+ output=output,
354
+ included_sections=tuple(included_sections),
355
+ omitted_sections=tuple(omitted_sections),
356
+ changes_truncated=changes_truncated,
357
+ commands_truncated=commands_truncated,
358
+ )
348
359
 
349
360
 
350
361
  def rendered_source_contents(
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "context-loader"
7
- version = "1.0.0"
7
+ version = "1.2.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.0.0"
7
+ version = "1.2.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.0.0"
49
+ "tool_version": "1.2.0"
48
50
  }
File without changes