agent2learn 0.1.2__tar.gz → 0.1.5__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.
Files changed (48) hide show
  1. {agent2learn-0.1.2 → agent2learn-0.1.5}/PKG-INFO +47 -35
  2. {agent2learn-0.1.2 → agent2learn-0.1.5}/README.md +46 -34
  3. {agent2learn-0.1.2 → agent2learn-0.1.5}/pyproject.toml +1 -1
  4. {agent2learn-0.1.2 → agent2learn-0.1.5}/pyproject.toml.orig +1 -1
  5. {agent2learn-0.1.2 → agent2learn-0.1.5}/skills/a2l-coursework/SKILL.md +1 -1
  6. {agent2learn-0.1.2 → agent2learn-0.1.5}/skills/a2l-setup/SKILL.md +1 -1
  7. {agent2learn-0.1.2 → agent2learn-0.1.5}/skills/a2l-study/SKILL.md +1 -1
  8. {agent2learn-0.1.2 → agent2learn-0.1.5}/skills/a2l-sync/SKILL.md +1 -1
  9. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/__init__.py +1 -1
  10. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/audit.py +17 -6
  11. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/calendar.py +18 -2
  12. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/cli.py +26 -7
  13. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/doctor.py +25 -2
  14. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/ingest.py +16 -2
  15. agent2learn-0.1.5/src/agent2learn/metadata_coverage.py +137 -0
  16. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/pipeline.py +3 -3
  17. {agent2learn-0.1.2 → agent2learn-0.1.5}/LICENSE +0 -0
  18. {agent2learn-0.1.2 → agent2learn-0.1.5}/skills.sh.json +0 -0
  19. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/_release.py +0 -0
  20. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/aipolicy.py +0 -0
  21. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/api.py +0 -0
  22. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/auth/__init__.py +0 -0
  23. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/auth/cdp.py +0 -0
  24. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/auth/paste.py +0 -0
  25. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/calibrate.py +0 -0
  26. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/check.py +0 -0
  27. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/clock.py +0 -0
  28. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/config.py +0 -0
  29. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/console.py +0 -0
  30. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/convert.py +0 -0
  31. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/errors.py +0 -0
  32. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/ground.py +0 -0
  33. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/index.py +0 -0
  34. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/locations.py +0 -0
  35. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/outlines.py +0 -0
  36. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/paths.py +0 -0
  37. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/privacy.py +0 -0
  38. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/schools/__init__.py +0 -0
  39. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/schools/_base.py +0 -0
  40. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/schools/generic.py +0 -0
  41. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/schools/uwaterloo.py +0 -0
  42. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/session.py +0 -0
  43. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/skills.py +0 -0
  44. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/snapshot.py +0 -0
  45. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/submit.py +0 -0
  46. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/transactions.py +0 -0
  47. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/upgrade.py +0 -0
  48. {agent2learn-0.1.2 → agent2learn-0.1.5}/src/agent2learn/vault.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent2learn
3
- Version: 0.1.2
3
+ Version: 0.1.5
4
4
  Summary: A local, source-cited course vault for AI agents
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -34,19 +34,15 @@ a durable local vault: original files beside Markdown twins, a stable index, and
34
34
  resolve to ordinary paths and line numbers. Your agent stops guessing about your course and starts
35
35
  quoting it.
36
36
 
37
- > **Walkthrough recording:** not yet attached. It is a release artifact, recorded against the
38
- > synthetic fixture course in `tests/fixtures/` — never against a real account — so no course,
39
- > student, grade, or session data appears in it. Recording it is a launch gate in
40
- > [docs/LAUNCH.md](docs/LAUNCH.md); this README will not link a file that does not exist.
41
-
42
37
  ## Install
43
38
 
44
- Three supported options. Pick one.
39
+ For most students, use the command for your operating system. In a real terminal it installs
40
+ Agent2Learn and starts guided setup immediately. If you already have uv, use the uv option below.
45
41
 
46
42
  **macOS and Linux**
47
43
 
48
44
  ```bash
49
- curl -fsSL https://raw.githubusercontent.com/ManagementMO/agent2learn/main/install.sh | bash
45
+ (installer="$(curl -fsSL https://raw.githubusercontent.com/ManagementMO/agent2learn/main/install.sh)" && bash -c "$installer")
50
46
  ```
51
47
 
52
48
  **Windows (PowerShell)**
@@ -57,21 +53,35 @@ irm https://raw.githubusercontent.com/ManagementMO/agent2learn/main/install.ps1
57
53
 
58
54
  **Already have [uv](https://docs.astral.sh/uv/)?**
59
55
 
56
+ Bash or zsh:
57
+
60
58
  ```bash
61
- uv tool install agent2learn
62
- a2l init
59
+ uv tool install agent2learn && uv tool run --from agent2learn a2l init
63
60
  ```
64
61
 
62
+ Windows PowerShell:
63
+
64
+ ```powershell
65
+ uv tool install agent2learn; if ($LASTEXITCODE -eq 0) { uv tool run --from agent2learn a2l init } else { throw "Agent2Learn installation failed" }
66
+ ```
67
+
68
+ These uv commands start setup through uv itself, so a newly installed `a2l` does not need to be
69
+ on the current shell's PATH first. `uv tool install agent2learn` remains the install-only command.
70
+
65
71
  These commands install the published package from PyPI, not an unreleased repository checkout.
66
72
  Python 3.11–3.14 is supported. If uv selects an older system Python or your shell cannot find
67
- `a2l`, see [setup troubleshooting](docs/FAQ.md#setup-and-path).
73
+ `a2l`, see [setup troubleshooting](https://github.com/ManagementMO/agent2learn/blob/main/docs/FAQ.md#setup-and-path).
68
74
 
69
- `install.sh` and `install.ps1` install a pinned Agent2Learn (currently 0.1.2), verify that `a2l`
70
- runs, and then **continue straight into interactive `a2l init`** in the same command. If they are
71
- run without a terminal on both ends — in CI, or through a pipe — they stop after verifying and
72
- print the exact next step instead of setting anything up. Neither script needs administrator
73
- rights, and neither creates a vault, installs agent skills, or opens a browser by itself.
74
- Onboarding does those things, after showing you a preview and asking.
75
+ `install.sh` and `install.ps1` install a pinned Agent2Learn release, verify that `a2l` runs,
76
+ and then **continue straight into interactive `a2l init`** in the same command. The
77
+ macOS/Linux launcher waits for a successful download and preserves your terminal for setup prompts.
78
+ If a script runs without a terminal on both ends, such as in CI or a headless agent tool, it stops
79
+ after verification and prints the next step instead of setting anything up.
80
+
81
+ Neither script needs administrator rights or creates a vault, installs agent skills, or opens a
82
+ browser by itself. Onboarding does those things after showing you a preview and asking; you still
83
+ complete LEARN sign-in and Duo yourself. Already-open terminals or agents may need to be restarted
84
+ to pick up the new executable path, but you do not need to repeat completed setup.
75
85
 
76
86
  ### Agent skills
77
87
 
@@ -83,7 +93,7 @@ Separately, `npx skills add ManagementMO/agent2learn` is an optional skills-only
83
93
  skills ecosystem. It **does not install** the `a2l` engine — only the skill documents — so the
84
94
  commands they describe will not exist until you install Agent2Learn itself with one of the three
85
95
  options above. That route also runs a third-party CLI with its own network behaviour; see
86
- [docs/PRIVACY.md](docs/PRIVACY.md).
96
+ [docs/PRIVACY.md](https://github.com/ManagementMO/agent2learn/blob/main/docs/PRIVACY.md).
87
97
 
88
98
  ## Then just ask
89
99
 
@@ -146,7 +156,7 @@ a2l submit COURSE ITEM FILE preview an upload, then require your typed conf
146
156
 
147
157
  Agent2Learn is **not affiliated with, endorsed by, or supported by the University of Waterloo or
148
158
  D2L Corporation**. You are responsible for using it within your course rules and your institution's
149
- policies. See [DISCLAIMER.md](DISCLAIMER.md).
159
+ policies. See [DISCLAIMER.md](https://github.com/ManagementMO/agent2learn/blob/main/DISCLAIMER.md).
150
160
 
151
161
  ## Privacy defaults
152
162
 
@@ -161,26 +171,28 @@ policies. See [DISCLAIMER.md](DISCLAIMER.md).
161
171
 
162
172
  Turning collection off does not delete what is already on disk; `a2l privacy purge` does that, and
163
173
  it previews the exact targets and requires a typed phrase first. Full detail, including every
164
- external request Agent2Learn can make, is in [docs/PRIVACY.md](docs/PRIVACY.md).
174
+ external request Agent2Learn can make, is in [docs/PRIVACY.md](https://github.com/ManagementMO/agent2learn/blob/main/docs/PRIVACY.md).
165
175
 
166
176
  ## Documentation
167
177
 
168
- - [Install guide for agents](docs/install.md) — point your agent here and let it handle setup.
169
- - [Authentication](docs/AUTHENTICATION.md) — the dedicated profile, Duo, expiry, and recovery.
170
- - [Privacy](docs/PRIVACY.md) — what is stored, where, every network action, and how to delete it.
171
- - [FAQ](docs/FAQ.md) — the things that actually go wrong.
172
- - [Porting to another school](docs/PORTING.md) — the `School` protocol and a worked reference.
173
- - [What is deferred, and why](docs/FUTURE.md)
174
- - [Security policy](SECURITY.md) · [Disclaimer](DISCLAIMER.md) · [Licence](LICENSE) ·
175
- [Third-party notices](THIRD_PARTY_NOTICES.md)
178
+ - [Install guide for agents](https://github.com/ManagementMO/agent2learn/blob/main/docs/install.md) — point your agent here and let it handle setup.
179
+ - [Authentication](https://github.com/ManagementMO/agent2learn/blob/main/docs/AUTHENTICATION.md) — the dedicated profile, Duo, expiry, and recovery.
180
+ - [Privacy](https://github.com/ManagementMO/agent2learn/blob/main/docs/PRIVACY.md) — what is stored, where, every network action, and how to delete it.
181
+ - [FAQ](https://github.com/ManagementMO/agent2learn/blob/main/docs/FAQ.md) — the things that actually go wrong.
182
+ - [Porting to another school](https://github.com/ManagementMO/agent2learn/blob/main/docs/PORTING.md) — the `School` protocol and a worked reference.
183
+ - [What is deferred, and why](https://github.com/ManagementMO/agent2learn/blob/main/docs/FUTURE.md)
184
+ - [Security policy](https://github.com/ManagementMO/agent2learn/blob/main/SECURITY.md) · [Disclaimer](https://github.com/ManagementMO/agent2learn/blob/main/DISCLAIMER.md) · [Licence](https://github.com/ManagementMO/agent2learn/blob/main/LICENSE) ·
185
+ [Third-party notices](https://github.com/ManagementMO/agent2learn/blob/main/THIRD_PARTY_NOTICES.md)
176
186
 
177
187
  ## Status
178
188
 
179
- Agent2Learn is in active development. This branch targets **0.1.2**; the pinned installers require
180
- that version to be available on PyPI before they can install it. A release-preparation branch is
181
- not evidence of publication. The repository holds the tested implementation of the full v0.1
182
- command surface; publication is gated on the release checks in [docs/LAUNCH.md](docs/LAUNCH.md),
183
- including live same-device authentication records on Windows, macOS, and Linux, and a supervised
184
- upload test before the submission path is enabled in any published build.
189
+ Agent2Learn is available on [PyPI](https://pypi.org/project/agent2learn/).
190
+ The latest [GitHub release](https://github.com/ManagementMO/agent2learn/releases/latest) carries the
191
+ same verified wheel and source distribution. Use one install command above to start guided setup.
192
+ If you are already installed or resuming setup, run `a2l init` in your terminal.
193
+
194
+ The project remains in active development. Release checks are documented in
195
+ [docs/LAUNCH.md](https://github.com/ManagementMO/agent2learn/blob/main/docs/LAUNCH.md), including same-device authentication and a fresh supervised upload
196
+ test before submission capability can be enabled. **LEARN uploads remain disabled in this build.**
185
197
 
186
- Licensed under [Apache-2.0](LICENSE).
198
+ Licensed under [Apache-2.0](https://github.com/ManagementMO/agent2learn/blob/main/LICENSE).
@@ -7,19 +7,15 @@ a durable local vault: original files beside Markdown twins, a stable index, and
7
7
  resolve to ordinary paths and line numbers. Your agent stops guessing about your course and starts
8
8
  quoting it.
9
9
 
10
- > **Walkthrough recording:** not yet attached. It is a release artifact, recorded against the
11
- > synthetic fixture course in `tests/fixtures/` — never against a real account — so no course,
12
- > student, grade, or session data appears in it. Recording it is a launch gate in
13
- > [docs/LAUNCH.md](docs/LAUNCH.md); this README will not link a file that does not exist.
14
-
15
10
  ## Install
16
11
 
17
- Three supported options. Pick one.
12
+ For most students, use the command for your operating system. In a real terminal it installs
13
+ Agent2Learn and starts guided setup immediately. If you already have uv, use the uv option below.
18
14
 
19
15
  **macOS and Linux**
20
16
 
21
17
  ```bash
22
- curl -fsSL https://raw.githubusercontent.com/ManagementMO/agent2learn/main/install.sh | bash
18
+ (installer="$(curl -fsSL https://raw.githubusercontent.com/ManagementMO/agent2learn/main/install.sh)" && bash -c "$installer")
23
19
  ```
24
20
 
25
21
  **Windows (PowerShell)**
@@ -30,21 +26,35 @@ irm https://raw.githubusercontent.com/ManagementMO/agent2learn/main/install.ps1
30
26
 
31
27
  **Already have [uv](https://docs.astral.sh/uv/)?**
32
28
 
29
+ Bash or zsh:
30
+
33
31
  ```bash
34
- uv tool install agent2learn
35
- a2l init
32
+ uv tool install agent2learn && uv tool run --from agent2learn a2l init
36
33
  ```
37
34
 
35
+ Windows PowerShell:
36
+
37
+ ```powershell
38
+ uv tool install agent2learn; if ($LASTEXITCODE -eq 0) { uv tool run --from agent2learn a2l init } else { throw "Agent2Learn installation failed" }
39
+ ```
40
+
41
+ These uv commands start setup through uv itself, so a newly installed `a2l` does not need to be
42
+ on the current shell's PATH first. `uv tool install agent2learn` remains the install-only command.
43
+
38
44
  These commands install the published package from PyPI, not an unreleased repository checkout.
39
45
  Python 3.11–3.14 is supported. If uv selects an older system Python or your shell cannot find
40
- `a2l`, see [setup troubleshooting](docs/FAQ.md#setup-and-path).
46
+ `a2l`, see [setup troubleshooting](https://github.com/ManagementMO/agent2learn/blob/main/docs/FAQ.md#setup-and-path).
41
47
 
42
- `install.sh` and `install.ps1` install a pinned Agent2Learn (currently 0.1.2), verify that `a2l`
43
- runs, and then **continue straight into interactive `a2l init`** in the same command. If they are
44
- run without a terminal on both ends — in CI, or through a pipe — they stop after verifying and
45
- print the exact next step instead of setting anything up. Neither script needs administrator
46
- rights, and neither creates a vault, installs agent skills, or opens a browser by itself.
47
- Onboarding does those things, after showing you a preview and asking.
48
+ `install.sh` and `install.ps1` install a pinned Agent2Learn release, verify that `a2l` runs,
49
+ and then **continue straight into interactive `a2l init`** in the same command. The
50
+ macOS/Linux launcher waits for a successful download and preserves your terminal for setup prompts.
51
+ If a script runs without a terminal on both ends, such as in CI or a headless agent tool, it stops
52
+ after verification and prints the next step instead of setting anything up.
53
+
54
+ Neither script needs administrator rights or creates a vault, installs agent skills, or opens a
55
+ browser by itself. Onboarding does those things after showing you a preview and asking; you still
56
+ complete LEARN sign-in and Duo yourself. Already-open terminals or agents may need to be restarted
57
+ to pick up the new executable path, but you do not need to repeat completed setup.
48
58
 
49
59
  ### Agent skills
50
60
 
@@ -56,7 +66,7 @@ Separately, `npx skills add ManagementMO/agent2learn` is an optional skills-only
56
66
  skills ecosystem. It **does not install** the `a2l` engine — only the skill documents — so the
57
67
  commands they describe will not exist until you install Agent2Learn itself with one of the three
58
68
  options above. That route also runs a third-party CLI with its own network behaviour; see
59
- [docs/PRIVACY.md](docs/PRIVACY.md).
69
+ [docs/PRIVACY.md](https://github.com/ManagementMO/agent2learn/blob/main/docs/PRIVACY.md).
60
70
 
61
71
  ## Then just ask
62
72
 
@@ -119,7 +129,7 @@ a2l submit COURSE ITEM FILE preview an upload, then require your typed conf
119
129
 
120
130
  Agent2Learn is **not affiliated with, endorsed by, or supported by the University of Waterloo or
121
131
  D2L Corporation**. You are responsible for using it within your course rules and your institution's
122
- policies. See [DISCLAIMER.md](DISCLAIMER.md).
132
+ policies. See [DISCLAIMER.md](https://github.com/ManagementMO/agent2learn/blob/main/DISCLAIMER.md).
123
133
 
124
134
  ## Privacy defaults
125
135
 
@@ -134,26 +144,28 @@ policies. See [DISCLAIMER.md](DISCLAIMER.md).
134
144
 
135
145
  Turning collection off does not delete what is already on disk; `a2l privacy purge` does that, and
136
146
  it previews the exact targets and requires a typed phrase first. Full detail, including every
137
- external request Agent2Learn can make, is in [docs/PRIVACY.md](docs/PRIVACY.md).
147
+ external request Agent2Learn can make, is in [docs/PRIVACY.md](https://github.com/ManagementMO/agent2learn/blob/main/docs/PRIVACY.md).
138
148
 
139
149
  ## Documentation
140
150
 
141
- - [Install guide for agents](docs/install.md) — point your agent here and let it handle setup.
142
- - [Authentication](docs/AUTHENTICATION.md) — the dedicated profile, Duo, expiry, and recovery.
143
- - [Privacy](docs/PRIVACY.md) — what is stored, where, every network action, and how to delete it.
144
- - [FAQ](docs/FAQ.md) — the things that actually go wrong.
145
- - [Porting to another school](docs/PORTING.md) — the `School` protocol and a worked reference.
146
- - [What is deferred, and why](docs/FUTURE.md)
147
- - [Security policy](SECURITY.md) · [Disclaimer](DISCLAIMER.md) · [Licence](LICENSE) ·
148
- [Third-party notices](THIRD_PARTY_NOTICES.md)
151
+ - [Install guide for agents](https://github.com/ManagementMO/agent2learn/blob/main/docs/install.md) — point your agent here and let it handle setup.
152
+ - [Authentication](https://github.com/ManagementMO/agent2learn/blob/main/docs/AUTHENTICATION.md) — the dedicated profile, Duo, expiry, and recovery.
153
+ - [Privacy](https://github.com/ManagementMO/agent2learn/blob/main/docs/PRIVACY.md) — what is stored, where, every network action, and how to delete it.
154
+ - [FAQ](https://github.com/ManagementMO/agent2learn/blob/main/docs/FAQ.md) — the things that actually go wrong.
155
+ - [Porting to another school](https://github.com/ManagementMO/agent2learn/blob/main/docs/PORTING.md) — the `School` protocol and a worked reference.
156
+ - [What is deferred, and why](https://github.com/ManagementMO/agent2learn/blob/main/docs/FUTURE.md)
157
+ - [Security policy](https://github.com/ManagementMO/agent2learn/blob/main/SECURITY.md) · [Disclaimer](https://github.com/ManagementMO/agent2learn/blob/main/DISCLAIMER.md) · [Licence](https://github.com/ManagementMO/agent2learn/blob/main/LICENSE) ·
158
+ [Third-party notices](https://github.com/ManagementMO/agent2learn/blob/main/THIRD_PARTY_NOTICES.md)
149
159
 
150
160
  ## Status
151
161
 
152
- Agent2Learn is in active development. This branch targets **0.1.2**; the pinned installers require
153
- that version to be available on PyPI before they can install it. A release-preparation branch is
154
- not evidence of publication. The repository holds the tested implementation of the full v0.1
155
- command surface; publication is gated on the release checks in [docs/LAUNCH.md](docs/LAUNCH.md),
156
- including live same-device authentication records on Windows, macOS, and Linux, and a supervised
157
- upload test before the submission path is enabled in any published build.
162
+ Agent2Learn is available on [PyPI](https://pypi.org/project/agent2learn/).
163
+ The latest [GitHub release](https://github.com/ManagementMO/agent2learn/releases/latest) carries the
164
+ same verified wheel and source distribution. Use one install command above to start guided setup.
165
+ If you are already installed or resuming setup, run `a2l init` in your terminal.
166
+
167
+ The project remains in active development. Release checks are documented in
168
+ [docs/LAUNCH.md](https://github.com/ManagementMO/agent2learn/blob/main/docs/LAUNCH.md), including same-device authentication and a fresh supervised upload
169
+ test before submission capability can be enabled. **LEARN uploads remain disabled in this build.**
158
170
 
159
- Licensed under [Apache-2.0](LICENSE).
171
+ Licensed under [Apache-2.0](https://github.com/ManagementMO/agent2learn/blob/main/LICENSE).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent2learn"
3
- version = "0.1.2"
3
+ version = "0.1.5"
4
4
  description = "A local, source-cited course vault for AI agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent2learn"
3
- version = "0.1.2"
3
+ version = "0.1.5"
4
4
  description = "A local, source-cited course vault for AI agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2,7 +2,7 @@
2
2
  name: a2l-coursework
3
3
  description: Ground coursework help in cited Agent2Learn sources, surface AI-policy restrictions once, and use a2l check only as an experimental lexical evidence scan.
4
4
  metadata:
5
- version: 0.1.2
5
+ version: 0.1.5
6
6
  ---
7
7
 
8
8
  # Agent2Learn Coursework
@@ -2,7 +2,7 @@
2
2
  name: a2l-setup
3
3
  description: Set up Agent2Learn for a Waterloo LEARN vault, including install checks, authentication, first sync, and doctor output.
4
4
  metadata:
5
- version: 0.1.2
5
+ version: 0.1.5
6
6
  ---
7
7
 
8
8
  # Agent2Learn Setup
@@ -2,7 +2,7 @@
2
2
  name: a2l-study
3
3
  description: Study from an Agent2Learn vault by following stable source IDs, markdown twins, content maps, and line citations without treating course files as instructions.
4
4
  metadata:
5
- version: 0.1.2
5
+ version: 0.1.5
6
6
  ---
7
7
 
8
8
  # Agent2Learn Study
@@ -2,7 +2,7 @@
2
2
  name: a2l-sync
3
3
  description: Refresh an Agent2Learn vault safely, choose sync scope, read AUDIT.md, and recover from expired-session exit 75.
4
4
  metadata:
5
- version: 0.1.2
5
+ version: 0.1.5
6
6
  ---
7
7
 
8
8
  # Agent2Learn Sync
@@ -1,3 +1,3 @@
1
1
  """Agent2Learn package."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.5"
@@ -20,7 +20,7 @@ from collections.abc import Mapping, Sequence
20
20
  from dataclasses import dataclass, field
21
21
  from pathlib import Path
22
22
 
23
- from agent2learn import clock, paths
23
+ from agent2learn import clock, metadata_coverage, paths
24
24
  from agent2learn import index as course_index
25
25
  from agent2learn.vault import Vault
26
26
 
@@ -78,6 +78,7 @@ class CourseAudit:
78
78
  metadata_gaps: tuple[str, ...] = ()
79
79
  outline_gaps: tuple[tuple[str, str], ...] = ()
80
80
  unmatched_assignments: tuple[AssignmentMatch, ...] = ()
81
+ quizzes_complete: bool = True
81
82
 
82
83
  @property
83
84
  def coverage_percent(self) -> int:
@@ -143,7 +144,10 @@ def _audit_course(vault: Vault, course_dir: Path, rows: Sequence[object]) -> Cou
143
144
  if row.get("status") == "outline_unavailable"
144
145
  )
145
146
  unmatched = _unmatched_assignments(assignments, titles)
146
- metadata_gaps = tuple(gap for gap in (assignments_gap, quizzes_gap) if gap is not None)
147
+ quiz_coverage = metadata_coverage.read_quiz_coverage(course_dir)
148
+ metadata_gaps = tuple(
149
+ gap for gap in (assignments_gap, quizzes_gap, quiz_coverage.gap) if gap is not None
150
+ )
147
151
 
148
152
  first = next((row for row in rows if isinstance(row, Mapping)), {})
149
153
  return CourseAudit(
@@ -161,6 +165,7 @@ def _audit_course(vault: Vault, course_dir: Path, rows: Sequence[object]) -> Cou
161
165
  metadata_gaps=metadata_gaps,
162
166
  outline_gaps=outline_gaps,
163
167
  unmatched_assignments=unmatched,
168
+ quizzes_complete=quiz_coverage.status == "complete" and quizzes_gap is None,
164
169
  )
165
170
 
166
171
 
@@ -295,8 +300,8 @@ def _render(audits: Sequence[CourseAudit], stamp: str) -> str:
295
300
  [
296
301
  "### Metadata gaps",
297
302
  "",
298
- "The local metadata projection could not be read completely; inventory "
299
- "counts below may be incomplete.",
303
+ "Metadata coverage is incomplete or unknown; the records below may be "
304
+ "cached and do not establish the current complete inventory.",
300
305
  "",
301
306
  *[f"- {gap}" for gap in audit.metadata_gaps],
302
307
  "",
@@ -327,10 +332,16 @@ def _render(audits: Sequence[CourseAudit], stamp: str) -> str:
327
332
  ["", "These are external or licensed targets. Open them in LEARN directly.", ""]
328
333
  )
329
334
 
335
+ quiz_inventory = (
336
+ f"- {_plural(audit.quizzes, 'quiz', 'quizzes')}"
337
+ f" ({audit.quizzes_with_due_dates} with due dates)"
338
+ if audit.quizzes_complete
339
+ else f"- Quiz inventory not confirmed; {audit.quizzes} cached quiz record(s)"
340
+ f" ({audit.quizzes_with_due_dates} with due dates)"
341
+ )
330
342
  counts = [
331
343
  f"- {_plural(audit.assignments, 'assignment')}",
332
- f"- {_plural(audit.quizzes, 'quiz', 'quizzes')}"
333
- f" ({audit.quizzes_with_due_dates} with due dates)",
344
+ quiz_inventory,
334
345
  f"- {_plural(audit.media, 'media file')}",
335
346
  ]
336
347
  lines.extend(["### Inventory", "", *counts, ""])
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
  from typing import Any, Literal, cast
20
20
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
21
21
 
22
- from agent2learn import clock, paths, snapshot
22
+ from agent2learn import clock, metadata_coverage, paths, snapshot
23
23
  from agent2learn.errors import A2LError
24
24
  from agent2learn.schools import School, parse_api_timestamp
25
25
  from agent2learn.vault import Vault
@@ -66,6 +66,7 @@ class TodayReport:
66
66
  overdue: tuple[CalendarEvent, ...]
67
67
  exam_countdowns: tuple[ExamCountdown, ...]
68
68
  changes: snapshot.SnapshotDiff
69
+ metadata_gaps: tuple[str, ...] = ()
69
70
 
70
71
 
71
72
  _DATE_ONLY = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@@ -225,6 +226,7 @@ def build_today(
225
226
  overdue=tuple(overdue),
226
227
  exam_countdowns=tuple(exam_countdowns),
227
228
  changes=snapshot.diff_vault(vault, include_grades=include_grades),
229
+ metadata_gaps=metadata_coverage.vault_quiz_gaps(vault),
228
230
  )
229
231
 
230
232
 
@@ -235,6 +237,15 @@ def render_today(report: TodayReport, *, include_grades: bool = False) -> str:
235
237
  f"Today · {report.as_of.astimezone(_zone(report.timezone)).date()} ({report.timezone})",
236
238
  "",
237
239
  ]
240
+ if report.metadata_gaps:
241
+ lines.extend(
242
+ [
243
+ "Metadata coverage is incomplete:",
244
+ *[f"- {gap}" for gap in report.metadata_gaps],
245
+ "Deadlines may be missing, and cached quiz dates may be outdated.",
246
+ "",
247
+ ]
248
+ )
238
249
  if report.exam_countdowns:
239
250
  lines.append("Exam countdown:")
240
251
  for countdown in report.exam_countdowns:
@@ -251,7 +262,12 @@ def render_today(report: TodayReport, *, include_grades: bool = False) -> str:
251
262
  lines.extend(f"- {_display_event(event)}" for event in report.due_soon)
252
263
  lines.append("")
253
264
  if not report.overdue and not report.due_soon:
254
- lines.extend(["No assignments or quizzes due within 7 days.", ""])
265
+ empty = (
266
+ "No known deadlines due within 7 days in the available local metadata."
267
+ if report.metadata_gaps
268
+ else "No assignments or quizzes due within 7 days."
269
+ )
270
+ lines.extend([empty, ""])
255
271
 
256
272
  change_text = snapshot.render_diff(report.changes, include_grades=include_grades).rstrip()
257
273
  if report.changes.has_baseline:
@@ -21,7 +21,7 @@ from typing import Annotated, Any, TypeVar
21
21
 
22
22
  import typer
23
23
 
24
- from agent2learn import __version__, clock, config, console, paths
24
+ from agent2learn import __version__, clock, config, console, metadata_coverage, paths
25
25
  from agent2learn import calendar as calendar_module
26
26
  from agent2learn import check as check_module
27
27
  from agent2learn import doctor as doctor_module
@@ -303,7 +303,7 @@ def sync(
303
303
  if report.exit_code == SessionExpired.exit_code:
304
304
  typer.echo("session expired · run: a2l auth", err=True)
305
305
  raise typer.Exit(code=SessionExpired.exit_code)
306
- typer.echo(pipeline_module.render_report(report), nl=False)
306
+ typer.echo(pipeline_module.render_report(report, include_metadata=False), nl=False)
307
307
  if report.exit_code:
308
308
  raise typer.Exit(code=report.exit_code)
309
309
 
@@ -1838,8 +1838,21 @@ def _print_metadata_summary(
1838
1838
  selected_count: int,
1839
1839
  include_grades: bool,
1840
1840
  ) -> None:
1841
+ gaps = (
1842
+ metadata_coverage.vault_quiz_gaps(Vault(root))
1843
+ if report is None
1844
+ else getattr(report, "gaps", ())
1845
+ )
1846
+ for gap in gaps:
1847
+ typer.echo(
1848
+ f"{console.GLYPH['warn']} {gap}; quiz/deadline coverage is not confirmed. "
1849
+ "See .a2l/AUDIT.md and check quiz availability in LEARN.",
1850
+ err=True,
1851
+ )
1841
1852
  if report is None:
1842
- typer.echo(f"{console.GLYPH['ok']} metadata is ready for {_term_label(school, term)}")
1853
+ typer.echo(
1854
+ f"{console.GLYPH['ok']} available metadata is ready for {_term_label(school, term)}"
1855
+ )
1843
1856
  return
1844
1857
  reports = getattr(report, "courses", ())
1845
1858
  course_count = len(reports) if isinstance(reports, Sequence) else selected_count
@@ -1852,6 +1865,7 @@ def _print_metadata_summary(
1852
1865
 
1853
1866
  assignment_count = 0
1854
1867
  quiz_count = 0
1868
+ quizzes_complete = True
1855
1869
  deadlines: list[tuple[str, str, str]] = []
1856
1870
  if isinstance(reports, Sequence):
1857
1871
  for course_report in reports:
@@ -1862,6 +1876,8 @@ def _print_metadata_summary(
1862
1876
  code = str(getattr(course, "code", "course"))
1863
1877
  assignments = _read_metadata_rows(directory / "_meta" / "assignments.json")
1864
1878
  quizzes = _read_metadata_rows(directory / "_meta" / "quizzes.json")
1879
+ coverage = metadata_coverage.read_quiz_coverage(directory)
1880
+ quizzes_complete = quizzes_complete and coverage.status == "complete"
1865
1881
  assignment_count += len(assignments)
1866
1882
  quiz_count += len(quizzes)
1867
1883
  for row in [*assignments, *quizzes]:
@@ -1871,9 +1887,12 @@ def _print_metadata_summary(
1871
1887
  deadlines.append((due, title, code))
1872
1888
 
1873
1889
  grade_text = "grades synced" if include_grades else "grades not synced"
1874
- detail = f"{deadline_count} deadlines"
1890
+ detail = f"{deadline_count} {'deadlines' if quizzes_complete else 'known deadlines'}"
1875
1891
  if assignment_count or quiz_count:
1876
- detail = f"{assignment_count} assignments · {quiz_count} quizzes · {detail}"
1892
+ quiz_inventory = (
1893
+ f"{quiz_count} quizzes" if quizzes_complete else "quiz inventory unconfirmed"
1894
+ )
1895
+ detail = f"{assignment_count} assignments · {quiz_inventory} · {detail}"
1877
1896
  typer.echo(
1878
1897
  f"{console.GLYPH['ok']} {course_count} courses · {topic_count} topics · "
1879
1898
  f"{detail} · {grade_text}"
@@ -1881,8 +1900,8 @@ def _print_metadata_summary(
1881
1900
  for due, title, code in _first_value_deadlines(deadlines, school):
1882
1901
  typer.echo(f" {code} · {title} — due {_format_deadline(due, school)}")
1883
1902
  if not deadlines:
1884
- typer.echo(f" No upcoming deadlines recorded in {_term_label(school, term)} metadata.")
1885
- del root
1903
+ qualifier = "No upcoming" if quizzes_complete else "No known"
1904
+ typer.echo(f" {qualifier} deadlines recorded in {_term_label(school, term)} metadata.")
1886
1905
 
1887
1906
 
1888
1907
  def _iter_report_topics(value: MetadataReport | Iterable[object] | None) -> Iterable[object]:
@@ -33,7 +33,7 @@ from urllib.parse import urlencode
33
33
 
34
34
  import requests
35
35
 
36
- from agent2learn import __version__, console, paths
36
+ from agent2learn import __version__, console, metadata_coverage, paths
37
37
  from agent2learn import config as config_module
38
38
  from agent2learn import session as session_module
39
39
  from agent2learn import skills as skills_module
@@ -89,7 +89,11 @@ class DoctorClient(Protocol):
89
89
  _COURSE_SOURCE_DIRECTORIES = frozenset(
90
90
  {"announcements", "assignments", "content", "quizzes", "outlines"}
91
91
  )
92
- _SAFE_PUBLIC_NOTES = {"fs.vault": "vault root `~`"}
92
+ _SAFE_PUBLIC_NOTES = {
93
+ "fs.vault": "vault root `~`",
94
+ "metadata.quizzes": "Quiz collection coverage is incomplete or unknown.",
95
+ "metadata.quizzes.forbidden": "Quiz collection unavailable (HTTP 403 Forbidden).",
96
+ }
93
97
  _PUBLIC_CHECK_NAMES = frozenset(
94
98
  {
95
99
  "config.load",
@@ -105,6 +109,8 @@ _PUBLIC_CHECK_NAMES = frozenset(
105
109
  "fs.longest_path",
106
110
  "fs.unavailable",
107
111
  "fs.vault",
112
+ "metadata.quizzes",
113
+ "metadata.quizzes.forbidden",
108
114
  "session.age",
109
115
  "session.api_versions",
110
116
  "session.backend",
@@ -630,6 +636,8 @@ def _vault(vault: Vault | None) -> list[Check]:
630
636
  gaps = 0
631
637
  empty_twins = 0
632
638
  unreadable_maps = 0
639
+ quiz_gaps: set[str] = set()
640
+ quizzes_forbidden = False
633
641
  term_stats: dict[str, list[int]] = {}
634
642
  try:
635
643
  map_paths = sorted(
@@ -650,6 +658,10 @@ def _vault(vault: Vault | None) -> list[Check]:
650
658
  unreadable_maps += 1
651
659
  continue
652
660
  courses += 1
661
+ coverage = metadata_coverage.read_quiz_coverage(map_path.parent.parent)
662
+ if coverage.gap is not None:
663
+ quiz_gaps.add(coverage.gap)
664
+ quizzes_forbidden = quizzes_forbidden or coverage.status == "unavailable"
653
665
  term = map_path.parent.parent.parent.name
654
666
  stats = term_stats.setdefault(term, [0, 0, 0, 0, 0])
655
667
  stats[0] += 1
@@ -730,6 +742,17 @@ def _vault(vault: Vault | None) -> list[Check]:
730
742
  None if gaps == 0 else "see .a2l/AUDIT.md",
731
743
  ),
732
744
  ]
745
+ quiz_check = "metadata.quizzes.forbidden" if quizzes_forbidden else "metadata.quizzes"
746
+ checks.append(
747
+ Check(
748
+ "Vault",
749
+ quiz_check,
750
+ "warn" if quiz_gaps else "ok",
751
+ "; ".join(sorted(quiz_gaps)) if quiz_gaps else "quiz collection coverage is complete",
752
+ "see .a2l/AUDIT.md; check quiz availability in LEARN" if quiz_gaps else None,
753
+ public=_SAFE_PUBLIC_NOTES[quiz_check] if quiz_gaps else None,
754
+ )
755
+ )
733
756
  checks.append(
734
757
  Check(
735
758
  "Vault",
@@ -29,7 +29,7 @@ from urllib.parse import urljoin, urlsplit, urlunsplit
29
29
 
30
30
  from requests import RequestException
31
31
 
32
- from agent2learn import api, clock, locations, paths, snapshot, transactions
32
+ from agent2learn import api, clock, locations, metadata_coverage, paths, snapshot, transactions
33
33
  from agent2learn import index as course_index
34
34
  from agent2learn.api import DownloadError, DownloadResult, FileTooLarge
35
35
  from agent2learn.calibrate import CourseRef, calibrate, load_calibration
@@ -140,6 +140,7 @@ class MetadataReport:
140
140
  deadline_count: int
141
141
  errors: tuple[str, ...] = ()
142
142
  exit_code: int = 0
143
+ gaps: tuple[str, ...] = ()
143
144
 
144
145
 
145
146
  @dataclass(frozen=True)
@@ -243,6 +244,7 @@ def ingest_metadata(
243
244
  courses = _selected_courses(client, term=term, only=only)
244
245
  reports: list[CourseMetadata] = []
245
246
  errors: list[str] = []
247
+ gaps: list[str] = []
246
248
  deadline_count = 0
247
249
 
248
250
  for course in courses:
@@ -349,8 +351,11 @@ def ingest_metadata(
349
351
  quizzes, quizzes_complete, quizzes_error = _fetch_collection(
350
352
  client, _endpoint_path(client, course, "quizzes/")
351
353
  )
352
- if quizzes_error is not None:
354
+ quiz_coverage = metadata_coverage.quiz_coverage(quizzes_complete, quizzes_error)
355
+ if quizzes_error is not None and quiz_coverage.status != "unavailable":
353
356
  errors.append(_safe_error("quizzes", quizzes_error))
357
+ if quiz_coverage.status == "unavailable" and quiz_coverage.gap is not None:
358
+ gaps.append(quiz_coverage.gap)
354
359
  quiz_rows = _project_quizzes(quizzes)
355
360
  quiz_rows = _merge_rows(
356
361
  _read_list(course_dir / "_meta" / "quizzes.json"),
@@ -359,6 +364,7 @@ def ingest_metadata(
359
364
  complete=quizzes_complete,
360
365
  )
361
366
  _write_list(course_dir / "_meta" / "quizzes.json", quiz_rows, root=vault.root)
367
+ metadata_coverage.write_quiz_coverage(course_dir, quiz_coverage, root=vault.root)
362
368
 
363
369
  if include_grades:
364
370
  grades, grades_complete, grades_error = _fetch_collection(
@@ -423,6 +429,7 @@ def ingest_metadata(
423
429
  topic_count=sum(len(report.topics) for report in reports),
424
430
  deadline_count=deadline_count,
425
431
  errors=tuple(errors),
432
+ gaps=tuple(sorted(set(gaps))),
426
433
  )
427
434
 
428
435
 
@@ -452,11 +459,16 @@ def load_metadata_report(
452
459
  ) -> MetadataReport:
453
460
  """Reconstruct a completed metadata report from validated local course projections."""
454
461
  reports: list[CourseMetadata] = []
462
+ errors: list[str] = []
463
+ gaps: list[str] = []
455
464
  deadline_count = 0
456
465
  for course in courses:
457
466
  course_dir = _course_directory(vault, school, course)
458
467
  topics = load_metadata_topics(vault, school, [course])
459
468
  module_tree = tuple(_read_toc_modules(course_dir))
469
+ coverage = metadata_coverage.read_quiz_coverage(course_dir)
470
+ if coverage.gap is not None:
471
+ (errors if coverage.status == "incomplete" else gaps).append(coverage.gap)
460
472
  deadline_count += sum(
461
473
  1
462
474
  for row in [
@@ -477,6 +489,8 @@ def load_metadata_report(
477
489
  courses=tuple(reports),
478
490
  topic_count=sum(len(report.topics) for report in reports),
479
491
  deadline_count=deadline_count,
492
+ errors=tuple(sorted(set(errors))),
493
+ gaps=tuple(sorted(set(gaps))),
480
494
  )
481
495
 
482
496
 
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Literal, cast
9
+
10
+ from requests import HTTPError
11
+
12
+ from agent2learn import paths
13
+ from agent2learn.vault import Vault
14
+
15
+ CoverageStatus = Literal["complete", "unavailable", "incomplete", "unknown"]
16
+ _PERMISSION = "Quizzing.SeeQuizzing"
17
+ _PROBLEM_TYPE = "http://docs.valence.desire2learn.com/res/apiprop.html#not-authorized"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class QuizCoverage:
22
+ status: CoverageStatus = "unknown"
23
+ http_status: int | None = None
24
+ error_code: str | None = None
25
+ permission: str | None = None
26
+
27
+ def __post_init__(self) -> None:
28
+ if self.status not in {"complete", "unavailable", "incomplete", "unknown"}:
29
+ raise ValueError("invalid quiz coverage status")
30
+ if self.http_status is not None and (
31
+ type(self.http_status) is not int or not 400 <= self.http_status <= 599
32
+ ):
33
+ raise ValueError("invalid quiz coverage HTTP status")
34
+ if self.error_code not in {None, "not_authorized"} or self.permission not in {
35
+ None,
36
+ _PERMISSION,
37
+ }:
38
+ raise ValueError("unreviewed quiz coverage diagnostic")
39
+ if self.status in {"complete", "unknown"} and any(
40
+ value is not None for value in (self.http_status, self.error_code, self.permission)
41
+ ):
42
+ raise ValueError("unexpected quiz coverage diagnostic")
43
+ if self.status == "unavailable" and self.http_status != 403:
44
+ raise ValueError("unavailable quiz coverage requires HTTP 403")
45
+ if (self.error_code or self.permission) and self.status != "unavailable":
46
+ raise ValueError("permission diagnostic requires unavailable quiz coverage")
47
+
48
+ @property
49
+ def gap(self) -> str | None:
50
+ if self.status == "complete":
51
+ return None
52
+ label = {
53
+ "unavailable": "quizzes unavailable",
54
+ "incomplete": "quizzes metadata incomplete",
55
+ "unknown": "quizzes coverage unknown",
56
+ }[self.status]
57
+ details = [f"HTTP {self.http_status}"] if self.http_status is not None else []
58
+ details.extend(value for value in (self.error_code, self.permission) if value is not None)
59
+ return label + (f" ({'; '.join(details)})" if details else "")
60
+
61
+
62
+ def quiz_coverage(complete: bool, error: Exception | None) -> QuizCoverage:
63
+ if complete:
64
+ return QuizCoverage("complete")
65
+ response = error.response if isinstance(error, HTTPError) else None
66
+ status = response.status_code if response is not None else None
67
+ if status != 403:
68
+ return QuizCoverage("incomplete", http_status=status)
69
+ error_code = None
70
+ permission = None
71
+ if response is not None and len(response.content) <= 8192:
72
+ try:
73
+ problem = response.json()
74
+ except ValueError:
75
+ problem = None
76
+ if isinstance(problem, dict) and problem.get("type") == _PROBLEM_TYPE:
77
+ error_code = "not_authorized"
78
+ detail = problem.get("detail")
79
+ if isinstance(detail, str) and re.search(
80
+ r"securityVariableName:\s*Quizzing\.SeeQuizzing\s*\]", detail
81
+ ):
82
+ permission = _PERMISSION
83
+ return QuizCoverage("unavailable", 403, error_code, permission)
84
+
85
+
86
+ def write_quiz_coverage(course_dir: Path, coverage: QuizCoverage, *, root: Path) -> None:
87
+ fields = {key: value for key, value in asdict(coverage).items() if value is not None}
88
+ payload = {"schema_version": 1, "collections": {"quizzes": fields}}
89
+ paths.atomic_write_text(
90
+ course_dir / "_meta" / "metadata_coverage.json",
91
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
92
+ root=root,
93
+ )
94
+
95
+
96
+ def read_quiz_coverage(course_dir: Path) -> QuizCoverage:
97
+ destination = course_dir / "_meta" / "metadata_coverage.json"
98
+ if paths.has_link_component(destination):
99
+ return QuizCoverage()
100
+ try:
101
+ with open(os.fspath(paths.long_path(destination)), encoding="utf-8", newline="") as handle:
102
+ payload = json.load(handle)
103
+ if not isinstance(payload, dict) or type(payload.get("schema_version")) is not int:
104
+ return QuizCoverage()
105
+ if payload["schema_version"] != 1 or not isinstance(payload.get("collections"), dict):
106
+ return QuizCoverage()
107
+ fields = payload["collections"].get("quizzes")
108
+ if not isinstance(fields, dict) or set(fields) - {
109
+ "status",
110
+ "http_status",
111
+ "error_code",
112
+ "permission",
113
+ }:
114
+ return QuizCoverage()
115
+ return QuizCoverage(
116
+ cast(CoverageStatus, fields.get("status")),
117
+ fields.get("http_status"),
118
+ fields.get("error_code"),
119
+ fields.get("permission"),
120
+ )
121
+ except (OSError, UnicodeError, ValueError, TypeError):
122
+ return QuizCoverage()
123
+
124
+
125
+ def vault_quiz_gaps(vault: Vault) -> tuple[str, ...]:
126
+ if not paths.long_path(vault.root).is_dir():
127
+ return ()
128
+ return tuple(
129
+ sorted(
130
+ {
131
+ gap
132
+ for path in paths.walk(vault.root)
133
+ if path.name == "content_map.json" and ".a2l" not in path.parts
134
+ if (gap := read_quiz_coverage(path.parent.parent).gap) is not None
135
+ }
136
+ )
137
+ )
@@ -285,7 +285,7 @@ def refresh_indexes(vault: Vault, school: School, metadata: MetadataReport) -> i
285
285
  return refreshed
286
286
 
287
287
 
288
- def render_report(report: PipelineReport) -> str:
288
+ def render_report(report: PipelineReport, *, include_metadata: bool = True) -> str:
289
289
  """Render stable terminal counts without echoing course names, IDs, paths, or source errors."""
290
290
 
291
291
  lines = [
@@ -318,7 +318,7 @@ def render_report(report: PipelineReport) -> str:
318
318
  )
319
319
  else:
320
320
  lines.append("sync complete")
321
- return "\n".join(lines) + "\n"
321
+ return "\n".join(lines if include_metadata else lines[1:]) + "\n"
322
322
 
323
323
 
324
324
  def _offering_ids(value: object) -> tuple[int, ...] | None:
@@ -356,7 +356,7 @@ def _result_status(
356
356
  *,
357
357
  outline_failure: bool = True,
358
358
  ) -> tuple[tuple[str, ...], tuple[str, ...], int]:
359
- gaps: list[str] = []
359
+ gaps: list[str] = list(metadata.gaps)
360
360
  errors: list[str] = []
361
361
  if metadata.errors or metadata.exit_code:
362
362
  errors.append("metadata incomplete")
File without changes
File without changes