alissa-tools-github-revloop 0.31.13__tar.gz → 0.31.17__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 (40) hide show
  1. {alissa_tools_github_revloop-0.31.13/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.17}/PKG-INFO +13 -1
  2. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/README.md +12 -0
  3. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/__main__.py +55 -0
  4. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/config.py +128 -1
  5. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/fleet_vitals.py +11 -1
  6. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/ghclient.py +58 -0
  7. alissa_tools_github_revloop-0.31.17/src/main/alissa/tools/github/revloop/janitor.py +373 -0
  8. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/loop.py +272 -1
  9. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/loop_events.py +79 -0
  10. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/proc.py +20 -0
  11. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/state.py +24 -5
  12. alissa_tools_github_revloop-0.31.17/src/main/alissa/tools/github/revloop/version +1 -0
  13. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +13 -1
  14. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
  15. alissa_tools_github_revloop-0.31.13/src/main/alissa/tools/github/revloop/version +0 -1
  16. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/LICENSE +0 -0
  17. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/MANIFEST.in +0 -0
  18. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/NOTICE +0 -0
  19. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/requirements.txt +0 -0
  20. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/setup.cfg +0 -0
  21. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/setup.py +0 -0
  22. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  24. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  25. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  26. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/parking.py +0 -0
  27. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  28. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  29. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  30. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/version.py +0 -0
  31. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  32. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  33. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  34. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  35. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  36. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  37. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  38. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  39. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  40. {alissa_tools_github_revloop-0.31.13 → alissa_tools_github_revloop-0.31.17}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.13
3
+ Version: 0.31.17
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -46,6 +46,18 @@ The rule: a distribution owns a leaf package and declares only that subtree
46
46
  `__init__.py` at any namespace level would claim it for one distribution and
47
47
  shadow the others.
48
48
 
49
+ ## Discovery PRs are not reviewed
50
+
51
+ A PR in a plans repository (`plan_repos`, `ALISSA_REVIEW_PLAN_REPOS`) whose
52
+ body carries `<!-- alissa-discovery v1 -->` on a line of its own and whose
53
+ every changed path is under `knowledge-base/` gets **no review round** — no
54
+ reviewer session, no review task — and one activity-comment line per head
55
+ saying why. A marked PR that touches anything else (or whose file list cannot
56
+ be read in full) is reviewed under the code directive, unchanged. The fleet
57
+ flag `discovery_review` (`ALISSA_REVIEW_DISCOVERY_REVIEW`, default off) turns
58
+ the skip off: every discovery PR is then reviewed like any other. See
59
+ *Discovery PRs* in the repository README.
60
+
49
61
  ## Install
50
62
 
51
63
  ```sh
@@ -24,6 +24,18 @@ The rule: a distribution owns a leaf package and declares only that subtree
24
24
  `__init__.py` at any namespace level would claim it for one distribution and
25
25
  shadow the others.
26
26
 
27
+ ## Discovery PRs are not reviewed
28
+
29
+ A PR in a plans repository (`plan_repos`, `ALISSA_REVIEW_PLAN_REPOS`) whose
30
+ body carries `<!-- alissa-discovery v1 -->` on a line of its own and whose
31
+ every changed path is under `knowledge-base/` gets **no review round** — no
32
+ reviewer session, no review task — and one activity-comment line per head
33
+ saying why. A marked PR that touches anything else (or whose file list cannot
34
+ be read in full) is reviewed under the code directive, unchanged. The fleet
35
+ flag `discovery_review` (`ALISSA_REVIEW_DISCOVERY_REVIEW`, default off) turns
36
+ the skip off: every discovery PR is then reviewed like any other. See
37
+ *Discovery PRs* in the repository README.
38
+
27
39
  ## Install
28
40
 
29
41
  ```sh
@@ -16,6 +16,10 @@ from .config import (
16
16
  FLEET_VITALS_ENV,
17
17
  HUB_ADD,
18
18
  HUB_SKIP,
19
+ JANITOR_ENABLED_ENV,
20
+ JANITOR_GRACE_MINUTES_ENV,
21
+ JANITOR_INTERVAL_MINUTES_ENV,
22
+ JANITOR_WARN_PCT_ENV,
19
23
  LOOP_EVENTS_ENV,
20
24
  ON_MISSING_CREATE,
21
25
  ON_MISSING_SKIP,
@@ -280,6 +284,45 @@ def build_parser() -> argparse.ArgumentParser:
280
284
  action="store_false",
281
285
  help="do not push fleet vitals even if the config enables it",
282
286
  )
287
+ janitor = over.add_mutually_exclusive_group()
288
+ janitor.add_argument(
289
+ "--janitor",
290
+ dest="janitor_enabled",
291
+ action="store_true",
292
+ default=None,
293
+ help="remove each hub's stale main/.review-scratch-* and REVIEW-* "
294
+ "folders that no live session or open round holds, prune the "
295
+ "worktree records, and warn on a full volume (the default). "
296
+ f"Overridden by ${JANITOR_ENABLED_ENV}",
297
+ )
298
+ janitor.add_argument(
299
+ "--no-janitor",
300
+ dest="janitor_enabled",
301
+ action="store_false",
302
+ help="never sweep the workspace, even if the config enables it",
303
+ )
304
+ over.add_argument(
305
+ "--janitor-grace-minutes",
306
+ type=int,
307
+ metavar="MINUTES",
308
+ help="how old (by mtime) a scratch folder or REVIEW- checkout must be "
309
+ "before the janitor may remove it (default: 1440). "
310
+ f"Overridden by ${JANITOR_GRACE_MINUTES_ENV}",
311
+ )
312
+ over.add_argument(
313
+ "--janitor-interval-minutes",
314
+ type=int,
315
+ metavar="MINUTES",
316
+ help="how often the janitor sweeps, at the end of a pass; 0 = every "
317
+ f"pass (default: 10). Overridden by ${JANITOR_INTERVAL_MINUTES_ENV}",
318
+ )
319
+ over.add_argument(
320
+ "--janitor-warn-pct",
321
+ type=int,
322
+ metavar="PERCENT",
323
+ help="workspace volume usage at or above which a sweep logs a "
324
+ f"WARNING; 0 = never (default: 80). Overridden by ${JANITOR_WARN_PCT_ENV}",
325
+ )
283
326
  over.add_argument(
284
327
  "--alissa-endpoint",
285
328
  dest="alissa_endpoint",
@@ -336,6 +379,10 @@ def overrides_from(args: argparse.Namespace) -> dict:
336
379
  "task_list_bow_id": args.task_list_bow_id,
337
380
  "loop_events_enabled": args.loop_events_enabled,
338
381
  "fleet_vitals_enabled": args.fleet_vitals_enabled,
382
+ "janitor_enabled": args.janitor_enabled,
383
+ "janitor_grace_minutes": args.janitor_grace_minutes,
384
+ "janitor_interval_minutes": args.janitor_interval_minutes,
385
+ "janitor_warn_pct": args.janitor_warn_pct,
339
386
  "alissa_endpoint": args.alissa_endpoint,
340
387
  "dry_run": args.dry_run,
341
388
  }
@@ -422,6 +469,14 @@ def main(argv: list[str] | None = None) -> int:
422
469
  config = resolve_config(args)
423
470
  log.info("workspace: %s", config.workspace_root)
424
471
  log.info("repos_source: %s", config.repos_source)
472
+ log.info(
473
+ "janitor: %s (grace %d min, every %s, warn at %s)",
474
+ "on" if config.janitor_enabled else "off",
475
+ config.janitor_grace_minutes,
476
+ f"{config.janitor_interval_minutes} min"
477
+ if config.janitor_interval_minutes else "pass",
478
+ f"{config.janitor_warn_pct}%" if config.janitor_warn_pct else "never",
479
+ )
425
480
  config = resolve_feed_authority(config)
426
481
 
427
482
  watcher = ReviewWatcher(config)
@@ -183,6 +183,7 @@ CONFIG_KEYS = (
183
183
  "authors",
184
184
  "plan_repos",
185
185
  "plan_authors",
186
+ "discovery_review",
186
187
  "operators",
187
188
  "agent_profile",
188
189
  "reviewer_login",
@@ -206,6 +207,10 @@ CONFIG_KEYS = (
206
207
  "prompt_kill_minutes",
207
208
  "prompt_max_answers",
208
209
  "waiting_dir",
210
+ "janitor_enabled",
211
+ "janitor_grace_minutes",
212
+ "janitor_interval_minutes",
213
+ "janitor_warn_pct",
209
214
  "alissa_endpoint",
210
215
  "dry_run",
211
216
  )
@@ -233,6 +238,14 @@ REPOS_SOURCE_ENV = "ALISSA_REVIEW_REPOS_SOURCE"
233
238
  BOWS_REFRESH_POLLS_ENV = "ALISSA_REVIEW_BOWS_REFRESH_POLLS"
234
239
  BOW_OWNERS_ENV = "ALISSA_REVIEW_BOW_OWNERS"
235
240
 
241
+ # The workspace janitor's four rails (issue #167). Same precedence and the
242
+ # same blank-falls-through rule as the bows rails, so a container can tune
243
+ # (or switch off) the janitor without a file edit on its volume.
244
+ JANITOR_ENABLED_ENV = "ALISSA_REVIEW_JANITOR_ENABLED"
245
+ JANITOR_GRACE_MINUTES_ENV = "ALISSA_REVIEW_JANITOR_GRACE_MINUTES"
246
+ JANITOR_INTERVAL_MINUTES_ENV = "ALISSA_REVIEW_JANITOR_INTERVAL_MINUTES"
247
+ JANITOR_WARN_PCT_ENV = "ALISSA_REVIEW_JANITOR_WARN_PCT"
248
+
236
249
  # The plan directive's two allowlists (issue #148; genloop design §6.1, D12).
237
250
  # A PR gets the PLAN directive only when all four signals hold, and two of
238
251
  # them are fleet configuration: the repository is one of `plan_repos` and the
@@ -256,6 +269,15 @@ BOW_OWNERS_ENV = "ALISSA_REVIEW_BOW_OWNERS"
256
269
  PLAN_REPOS_ENV = "ALISSA_REVIEW_PLAN_REPOS"
257
270
  PLAN_AUTHORS_ENV = "ALISSA_REVIEW_PLAN_AUTHORS"
258
271
  PLAN_REPO_OWNER_PLACEHOLDER = "<owner>"
272
+
273
+ # The discovery fleet flag (issue #171; build-discovery design §3.3, DS-7).
274
+ # OFF by default: a DISCOVERY PR -- a plans repository, the discovery marker,
275
+ # every changed path under `knowledge-base/` (loop.discovery_skip_reason) --
276
+ # gets no review round at all, because its defence is the knowledge-base lint
277
+ # of record, staleness, the plan refuter and the user's acceptance. ON, every
278
+ # discovery PR is reviewed under the code directive like any other PR. Same
279
+ # env > file > default precedence and boolean spellings as the janitor's rail.
280
+ DISCOVERY_REVIEW_ENV = "ALISSA_REVIEW_DISCOVERY_REVIEW"
259
281
  DEFAULT_PLAN_REPO_NAME = "alissa-dark-factory-plans"
260
282
  DEFAULT_PLAN_REPOS: "tuple[str, ...]" = (
261
283
  f"{PLAN_REPO_OWNER_PLACEHOLDER}/{DEFAULT_PLAN_REPO_NAME}",
@@ -408,6 +430,36 @@ def env_bows_refresh_polls(
408
430
  ) from None
409
431
 
410
432
 
433
+ def _env_int(name: str, what: str, environ: "Mapping[str, str] | None") -> "int | None":
434
+ """One integer env rail, or None when unset/blank. A non-integer raises,
435
+ naming the variable -- the startup phase turns that into `config error`."""
436
+ raw = _env_text(name, environ)
437
+ if raw is None:
438
+ return None
439
+ try:
440
+ return int(raw)
441
+ except ValueError:
442
+ raise ValueError(f"{name} must be an integer {what}, got {raw!r}") from None
443
+
444
+
445
+ def env_janitor(environ: "Mapping[str, str] | None" = None) -> "dict[str, Any]":
446
+ """The janitor keys the environment sets (issue #167), as config-key ->
447
+ value; a key whose variable is unset or blank is absent."""
448
+ out: "dict[str, Any]" = {}
449
+ enabled = _env_bool(JANITOR_ENABLED_ENV, environ)
450
+ if enabled is not None:
451
+ out["janitor_enabled"] = enabled
452
+ for key, name, what in (
453
+ ("janitor_grace_minutes", JANITOR_GRACE_MINUTES_ENV, "number of minutes"),
454
+ ("janitor_interval_minutes", JANITOR_INTERVAL_MINUTES_ENV, "number of minutes"),
455
+ ("janitor_warn_pct", JANITOR_WARN_PCT_ENV, "percentage"),
456
+ ):
457
+ value = _env_int(name, what, environ)
458
+ if value is not None:
459
+ out[key] = value
460
+ return out
461
+
462
+
411
463
  def env_bow_owners(environ: "Mapping[str, str] | None" = None) -> "str | None":
412
464
  """`ALISSA_REVIEW_BOW_OWNERS` verbatim, or None when unset/blank. The
413
465
  `|`/`,` splitting and the actor-id check happen in `normalize_bow_owners`,
@@ -663,6 +715,23 @@ DEFAULT_VERDICT_COOLDOWN_SECONDS = 120
663
715
  DEFAULT_REVIEW_TASK_MISS_TTL_POLLS = 10
664
716
 
665
717
 
718
+ # -- the workspace janitor (issue #167) ---------------------------------------
719
+ #
720
+ # How old a `.review-scratch-*` or `REVIEW-*` folder must be (by mtime) before
721
+ # the janitor may remove it. A day: far past any round's life (the stale-round
722
+ # window is 90 minutes), so the janitor only ever takes what every round that
723
+ # could want it has long finished with -- and the live-session / open-round
724
+ # guard holds anything a slow round is still using regardless.
725
+ DEFAULT_JANITOR_GRACE_MINUTES = 24 * 60
726
+
727
+ # How often the janitor sweeps, at the end of a pass. 0 = every pass.
728
+ DEFAULT_JANITOR_INTERVAL_MINUTES = 10
729
+
730
+ # The workspace volume's used percentage at or above which a sweep logs one
731
+ # WARNING. Devloop's threshold; 0 = never warn.
732
+ DEFAULT_JANITOR_WARN_PCT = 80
733
+
734
+
666
735
  # -- the product-stability guard (issue #105) ---------------------------------
667
736
  #
668
737
  # How many consecutive `request_changes` rounds must move NOTHING outside
@@ -837,6 +906,10 @@ class Config:
837
906
  plan_repos: tuple[str, ...] = DEFAULT_PLAN_REPOS
838
907
  plan_authors: tuple[str, ...] = ()
839
908
 
909
+ # Review discovery PRs after all (issue #171); see DISCOVERY_REVIEW_ENV.
910
+ # False -- the default -- skips them.
911
+ discovery_review: bool = False
912
+
840
913
  # GitHub logins whose re-entry ack may raise a capped PR's effective cap
841
914
  # (loop.parse_reentry_ack). Empty -- the default -- means NO ack is ever
842
915
  # honoured: the lever fails closed, because anyone who can comment on a PR
@@ -978,6 +1051,18 @@ class Config:
978
1051
  # writes the same value here so the daemon looks where the hooks wrote.
979
1052
  waiting_dir: str = DEFAULT_WAITING_DIR
980
1053
 
1054
+ # The workspace janitor (issue #167; janitor.py): every
1055
+ # `janitor_interval_minutes` (0 = every pass), at the end of a pass, remove
1056
+ # each hub's `main/.review-scratch-*` and `REVIEW-*` folders older than
1057
+ # `janitor_grace_minutes` that no live session or open ledger round holds,
1058
+ # prune `.source`'s worktree records, and WARN once when the workspace
1059
+ # volume is at or above `janitor_warn_pct` (0 = never). ON by default: the
1060
+ # disk it reclaims fills a shared volume within weeks without it.
1061
+ janitor_enabled: bool = True
1062
+ janitor_grace_minutes: int = DEFAULT_JANITOR_GRACE_MINUTES
1063
+ janitor_interval_minutes: int = DEFAULT_JANITOR_INTERVAL_MINUTES
1064
+ janitor_warn_pct: int = DEFAULT_JANITOR_WARN_PCT
1065
+
981
1066
  dry_run: bool = False
982
1067
 
983
1068
  def __post_init__(self) -> None:
@@ -1112,7 +1197,7 @@ class Config:
1112
1197
 
1113
1198
  `environ` is the fourth layer and applies to the env-backed keys
1114
1199
  (`task_list_bow_id`, `loop_events_enabled`, `fleet_vitals_enabled`,
1115
- `repos_source`, `bows_refresh_polls`, `bow_owners`); it wins over both of the others
1200
+ `repos_source`, `bows_refresh_polls`, `bow_owners`, the `janitor_*` keys); it wins over both of the others
1116
1201
  (see the module docstring). Defaults to the real environment, so
1117
1202
  callers that do not care pass nothing.
1118
1203
  """
@@ -1187,6 +1272,8 @@ class Config:
1187
1272
  env_owners = env_bow_owners(environ)
1188
1273
  if env_owners is not None:
1189
1274
  raw["bow_owners"] = env_owners
1275
+ # The janitor's rails (issue #167), same layer, same reason.
1276
+ raw.update(env_janitor(environ))
1190
1277
  # The plan allowlists (issue #148), same rail, same reason.
1191
1278
  env_plan_repos = env_plan_list(PLAN_REPOS_ENV, environ)
1192
1279
  if env_plan_repos is not None:
@@ -1194,6 +1281,16 @@ class Config:
1194
1281
  env_plan_authors = env_plan_list(PLAN_AUTHORS_ENV, environ)
1195
1282
  if env_plan_authors is not None:
1196
1283
  raw["plan_authors"] = env_plan_authors
1284
+ # The discovery flag (issue #171), same layer, same reason.
1285
+ env_discovery = _env_bool(DISCOVERY_REVIEW_ENV, environ)
1286
+ if env_discovery is not None:
1287
+ raw["discovery_review"] = env_discovery
1288
+ discovery_review = raw.get("discovery_review", cls.discovery_review)
1289
+ if not isinstance(discovery_review, bool):
1290
+ # A JSON string "false" is truthy; refused rather than read as on.
1291
+ raise ValueError(
1292
+ f"discovery_review must be true or false, got {discovery_review!r}"
1293
+ )
1197
1294
 
1198
1295
  source = raw.get("repos_source", cls.repos_source)
1199
1296
  if source not in _REPOS_SOURCES:
@@ -1439,6 +1536,31 @@ class Config:
1439
1536
  if not waiting_dir:
1440
1537
  raise ValueError("waiting_dir must be a non-empty path")
1441
1538
 
1539
+ janitor_grace = int(
1540
+ raw.get("janitor_grace_minutes", cls.janitor_grace_minutes)
1541
+ )
1542
+ if janitor_grace < 1:
1543
+ # No 0-means-off here: `janitor_enabled` is the off switch, and a
1544
+ # zero grace would make a scratch folder removable the minute a
1545
+ # guard misses it.
1546
+ raise ValueError(
1547
+ f"janitor_grace_minutes must be >= 1, got {janitor_grace}"
1548
+ )
1549
+ janitor_interval = int(
1550
+ raw.get("janitor_interval_minutes", cls.janitor_interval_minutes)
1551
+ )
1552
+ if janitor_interval < 0:
1553
+ raise ValueError(
1554
+ f"janitor_interval_minutes must be >= 0 (0 = every pass), "
1555
+ f"got {janitor_interval}"
1556
+ )
1557
+ janitor_warn = int(raw.get("janitor_warn_pct", cls.janitor_warn_pct))
1558
+ if not 0 <= janitor_warn <= 100:
1559
+ raise ValueError(
1560
+ f"janitor_warn_pct must be between 0 and 100 (0 = never warn), "
1561
+ f"got {janitor_warn}"
1562
+ )
1563
+
1442
1564
  state_path = raw.get("state_path")
1443
1565
  return cls(
1444
1566
  workspace_root=Path(workspace_root),
@@ -1454,6 +1576,7 @@ class Config:
1454
1576
  authors=authors,
1455
1577
  plan_repos=plan_repos,
1456
1578
  plan_authors=plan_authors,
1579
+ discovery_review=discovery_review,
1457
1580
  operators=operators,
1458
1581
  agent_profile=raw.get("agent_profile", "claude"),
1459
1582
  reviewer_login=raw.get("reviewer_login"),
@@ -1477,6 +1600,10 @@ class Config:
1477
1600
  prompt_kill_minutes=prompt_kill,
1478
1601
  prompt_max_answers=prompt_answers,
1479
1602
  waiting_dir=waiting_dir,
1603
+ janitor_enabled=bool(raw.get("janitor_enabled", cls.janitor_enabled)),
1604
+ janitor_grace_minutes=janitor_grace,
1605
+ janitor_interval_minutes=janitor_interval,
1606
+ janitor_warn_pct=janitor_warn,
1480
1607
  alissa_endpoint=endpoint,
1481
1608
  dry_run=bool(raw.get("dry_run", False)),
1482
1609
  )
@@ -86,6 +86,7 @@ from .alissa_client import (
86
86
  AlissaError,
87
87
  )
88
88
  from .config import REPOS_BOWS, Config
89
+ from .janitor import disk_used_pct as volume_used_pct
89
90
  from .proc import CommandError, run as proc_run
90
91
 
91
92
  if TYPE_CHECKING: # pragma: no cover - typing only
@@ -117,7 +118,10 @@ VITALS_FAILED = "failed"
117
118
  # The blocks Studio's schema gained after the base contract, each in its own
118
119
  # lane: `idle` (seat parking L3) and `config` (provisioner L5). A strict 400
119
120
  # naming one of them is an old Studio, not a bad snapshot.
120
- OPTIONAL_BLOCKS = ("idle", "config")
121
+ #
122
+ # `diskUsedPct` (issue #167) joins them: the workspace volume's used percent,
123
+ # which Studio's schema may not carry yet.
124
+ OPTIONAL_BLOCKS = ("idle", "config", "diskUsedPct")
121
125
 
122
126
  # How long a snapshot leaves an optional block out after Studio refused it as
123
127
  # an unknown key, before it offers the block again. An hour: one extra POST
@@ -296,6 +300,7 @@ def build_snapshot(
296
300
  repos: "tuple[str, ...]" = (),
297
301
  now: "int | float | None" = None,
298
302
  config: "Mapping | None" = None,
303
+ disk_used_pct: "float | None" = None,
299
304
  ) -> dict:
300
305
  """The contract body for this pass, off the console's builders.
301
306
 
@@ -314,6 +319,9 @@ def build_snapshot(
314
319
 
315
320
  `config` is the seat's `config_block`, or None to leave the key out (the
316
321
  console's push, which has no derived answer to give).
322
+
323
+ `diskUsedPct` (issue #167) is the workspace volume's used percent, null
324
+ when it could not be read.
317
325
  """
318
326
  stamp = time.time() if now is None else now
319
327
  snaps = sources.snapshots(DURATION_POINTS)
@@ -365,6 +373,7 @@ def build_snapshot(
365
373
  "queueDepth": _int_or_none(queue_depth),
366
374
  "kpis": None,
367
375
  "inbox": inbox,
376
+ "diskUsedPct": disk_used_pct,
368
377
  "idle": idle,
369
378
  }
370
379
  if config is not None:
@@ -519,6 +528,7 @@ class FleetVitalsPusher:
519
528
  repos=self.config.repos,
520
529
  now=self._clock(),
521
530
  config=self._config(),
531
+ disk_used_pct=volume_used_pct(self.config.workspace_root),
522
532
  ))
523
533
 
524
534
  def _config(self) -> "Mapping | None":
@@ -93,6 +93,12 @@ COMPARE_COMMITS_PER_PAGE = 1
93
93
  # gone?" probe would answer yes on every large PR and abandon real verdicts.
94
94
  PR_COMMIT_CAP = 250
95
95
 
96
+ # GitHub's OWN cap on the pull-request files endpoint: it "lists a maximum of
97
+ # 3000 files" (30 pages of 100). Past it the listing is silently short, so a
98
+ # PR at the cap cannot prove that EVERY changed path is under some prefix --
99
+ # which is the one question `pull_request_files` is asked (issue #171).
100
+ PR_FILES_CAP = 3000
101
+
96
102
  # Environment variables `gh` reads a token from. Both are cleared before an
97
103
  # explicitly-routed call, so an inherited implementer credential cannot win by
98
104
  # being the one variable we did not think to overwrite.
@@ -783,6 +789,58 @@ class GitHub:
783
789
  return out
784
790
  page += 1
785
791
 
792
+ def pull_request_files(self, owner: str, repo: str, number: int) -> list[str]:
793
+ """Every path the PR changes, paged to the end.
794
+
795
+ Read on ONE path: the discovery skip (issue #171), which asks whether
796
+ EVERY changed path is under `knowledge-base/`. That is an absence
797
+ question again -- "nothing outside the prefix" -- so a listing at
798
+ GitHub's PR_FILES_CAP is REFUSED (`TruncatedListing`) rather than
799
+ answered short, and the caller reviews the PR instead of skipping it.
800
+
801
+ A RENAME contributes BOTH names, for the reason `compare_files` gives:
802
+ `src/x.py` moved to `knowledge-base/x.py` changed a path outside the
803
+ prefix, and reading only the destination would make it look confined.
804
+ """
805
+ out: list[str] = []
806
+ seen: set[str] = set()
807
+ listed = 0
808
+ page = 1
809
+ while True:
810
+ data = (
811
+ self._api(
812
+ "-X",
813
+ "GET",
814
+ f"repos/{owner}/{repo}/pulls/{number}/files",
815
+ "-f",
816
+ f"per_page={PER_PAGE}",
817
+ "-f",
818
+ f"page={page}",
819
+ )
820
+ or []
821
+ )
822
+ listed += len(data)
823
+ for entry in data:
824
+ for key in ("filename", "previous_filename"):
825
+ name = str(entry.get(key) or "")
826
+ if name and name not in seen:
827
+ seen.add(name)
828
+ out.append(name)
829
+ if listed >= PR_FILES_CAP:
830
+ log.warning(
831
+ "%s/%s#%d reached GitHub's %d-file listing cap — the file "
832
+ "list is not complete, so 'nothing outside the prefix' "
833
+ "cannot be read from it",
834
+ owner, repo, number, PR_FILES_CAP,
835
+ )
836
+ raise TruncatedListing(
837
+ f"{owner}/{repo}#{number} reached GitHub's {PR_FILES_CAP}-file "
838
+ f"listing cap; absence cannot be proven from it"
839
+ )
840
+ if len(data) < PER_PAGE:
841
+ return out
842
+ page += 1
843
+
786
844
  # -- CI check rollup ---------------------------------------------------
787
845
 
788
846
  def check_rollup(self, owner: str, repo: str, sha: str) -> CheckRollup: