jekyll-theme-zer0 1.22.0 → 1.23.0

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.
data/_layouts/author.html CHANGED
@@ -68,10 +68,31 @@ hide_intro: true
68
68
  <!-- ========================== -->
69
69
  <!-- BREADCRUMB -->
70
70
  <!-- ========================== -->
71
+ {%- comment -%}
72
+ Only link the /authors/ breadcrumb crumb when that index page actually exists
73
+ in the build. A pure remote-theme Pages consumer doesn't get the
74
+ plugin-generated /authors/ index, so rendering the link would produce a 404.
75
+ Mirror the existence guard used in _includes/navigation/breadcrumbs.html
76
+ and _includes/components/author-bio.html. See issue #204.
77
+ {%- endcomment -%}
78
+ {%- assign _authors_url = '/authors/' -%}
79
+ {%- assign _authors_page = site.html_pages | where: "url", _authors_url | first -%}
80
+ {%- unless _authors_page -%}
81
+ {%- for _col in site.collections -%}
82
+ {%- assign _authors_page = _col.docs | where: "url", _authors_url | first -%}
83
+ {%- if _authors_page -%}{%- break -%}{%- endif -%}
84
+ {%- endfor -%}
85
+ {%- endunless -%}
71
86
  <nav aria-label="breadcrumb" class="mb-3">
72
87
  <ol class="breadcrumb">
73
88
  <li class="breadcrumb-item"><a href="{{ '/' | relative_url }}">Home</a></li>
74
- <li class="breadcrumb-item"><a href="{{ '/authors/' | relative_url }}">Authors</a></li>
89
+ <li class="breadcrumb-item">
90
+ {%- if _authors_page -%}
91
+ <a href="{{ _authors_url | relative_url }}">Authors</a>
92
+ {%- else -%}
93
+ Authors
94
+ {%- endif -%}
95
+ </li>
75
96
  <li class="breadcrumb-item active" aria-current="page">{{ author_name }}</li>
76
97
  </ol>
77
98
  </nav>
data/_layouts/root.html CHANGED
@@ -31,7 +31,21 @@
31
31
  -->
32
32
 
33
33
  <!doctype html>
34
- <html lang="{{ site.locale | slice: 0,2 | default: 'en' }}" class="no-js" data-bs-theme="dark" data-theme-skin="{{ site.theme_skin | default: 'dark' }}" data-zer0-bg="{{ site.theme_background.enabled | default: true }}">
34
+ {%- comment -%}
35
+ Resolve the server-side data-bs-theme:
36
+ dark → dark
37
+ light → light
38
+ auto → dark (inline script in tokens-inline.html corrects this before first paint)
39
+ We also store the raw config value in data-color-mode-default so the inline
40
+ script can read it without a Liquid evaluation.
41
+ {%- endcomment -%}
42
+ {%- assign _cm = site.color_mode_default | default: 'auto' -%}
43
+ {%- if _cm == 'light' -%}
44
+ {%- assign _bs_theme = 'light' -%}
45
+ {%- else -%}
46
+ {%- assign _bs_theme = 'dark' -%}
47
+ {%- endif -%}
48
+ <html lang="{{ site.locale | slice: 0,2 | default: 'en' }}" class="no-js" data-bs-theme="{{ _bs_theme }}" data-color-mode-default="{{ _cm }}" data-theme-skin="{{ site.theme_skin | default: 'dark' }}" data-zer0-bg="{{ site.theme_background.enabled | default: true }}">
35
49
  <head>
36
50
  <!-- =============================================== -->
37
51
  <!-- HEAD SECTION: Meta tags, styles, and SEO setup -->
data/scripts/bin/validate CHANGED
@@ -413,6 +413,7 @@ classified_files = %w[
413
413
  _config_secrets_local.yml
414
414
  pages/_about/settings/_config.yml
415
415
  .github/ISSUE_TEMPLATE/config.yml
416
+ .issues/config.yml
416
417
  ]
417
418
 
418
419
  # T-018: the admin config page renders pages/_about/settings/_config.yml via
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ classify_changes.py — the auto-merge smuggle guard.
4
+
5
+ Reads a list of changed file paths (one per line on stdin, e.g. from
6
+ `gh pr diff <n> --name-only`) and prints the distinct CATEGORIES present, one
7
+ per line, from:
8
+
9
+ content — pages/**, _data/{quests,navigation,...}, assets/** (the site)
10
+ infra — .github/**, scripts/**, Dockerfile, docker-compose, Gemfile*, Makefile
11
+ config — _config*.yml, _data/ai.yml, _data/brand/**, .cms/**, *.json config
12
+ data — other _data/**
13
+
14
+ The content auto-merge workflow refuses to merge any PR whose categories include
15
+ anything other than `content` — so an `auto:content` PR can never quietly carry a
16
+ workflow, dependency, brand-rule, or AI-config change to main without a human.
17
+ Unknown paths classify as `infra` (fail safe — when unsure, require a human).
18
+
19
+ The content category is deliberately broad (it includes theme files like
20
+ `_layouts/**`, `_includes/**`, `_sass/**`). A caller that needs a TIGHTER scope
21
+ than "any content" — e.g. the issue autopilot, whose resolver may only edit
22
+ `pages/**`, `assets/**`, `_data/quests/**` — adds `--allow-globs` to require that
23
+ every changed path also matches one of the given globs. `--content-only` and
24
+ `--allow-globs` compose: both must pass.
25
+
26
+ Usage:
27
+ gh pr diff 123 --name-only | python3 scripts/ci/classify_changes.py
28
+ python3 scripts/ci/classify_changes.py a.md b.yml # paths as args
29
+ ... | python3 scripts/ci/classify_changes.py --content-only # exit 0 iff content-only
30
+ ... | python3 scripts/ci/classify_changes.py --content-only \
31
+ --allow-globs 'pages/**' 'assets/**' '_data/quests/**' # tighter scope
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import sys
37
+ from fnmatch import fnmatch
38
+
39
+ # Order matters: first matching rule wins. Most specific first.
40
+ # THEME REPO: the theme itself (_layouts/_includes/_sass/_plugins/lib/assets) is
41
+ # the PRODUCT, so it is `infra` (never auto-mergeable) — only docs/pages Markdown
42
+ # counts as `content`. This is the key difference from a content site.
43
+ RULES = [
44
+ ("config", [
45
+ "_config.yml", "_config*.yml",
46
+ "_data/ai.yml",
47
+ ".github/config/*", ".github/config/**",
48
+ "frontmatter.json", ".frontmatter/*", ".frontmatter/**",
49
+ ]),
50
+ ("infra", [
51
+ ".github/*", ".github/**",
52
+ ".claude/*", ".claude/**",
53
+ "scripts/*", "scripts/**",
54
+ "lib/*", "lib/**",
55
+ "_layouts/*", "_layouts/**", "_includes/*", "_includes/**",
56
+ "_sass/*", "_sass/**", "_plugins/*", "_plugins/**",
57
+ "assets/*", "assets/**",
58
+ "test/*", "test/**", "templates/*", "templates/**",
59
+ "Dockerfile", "docker-compose*.yml", "docker-compose*.yaml",
60
+ "Gemfile", "Gemfile.lock", "Makefile", "Rakefile", "install.sh",
61
+ "*.gemspec", "package.json", "package-lock.json",
62
+ ]),
63
+ ("content", [
64
+ "docs/*", "docs/**",
65
+ "pages/*", "pages/**",
66
+ "index.html", "index.md", "*.md",
67
+ ]),
68
+ ]
69
+ FALLBACK = "infra" # unknown -> require a human (fail safe)
70
+
71
+
72
+ def classify_one(path: str) -> str:
73
+ p = path.strip()
74
+ if not p:
75
+ return ""
76
+ for category, patterns in RULES:
77
+ for pat in patterns:
78
+ if fnmatch(p, pat):
79
+ return category
80
+ return "data" if p.startswith("_data/") else FALLBACK
81
+
82
+
83
+ def main(argv=None) -> int:
84
+ ap = argparse.ArgumentParser(description="Classify changed paths for the auto-merge guard.")
85
+ ap.add_argument("paths", nargs="*", help="paths (else read stdin, one per line)")
86
+ ap.add_argument("--content-only", action="store_true",
87
+ help="exit 0 iff every path is content; exit 1 otherwise")
88
+ ap.add_argument("--allow-globs", nargs="*", default=None, metavar="GLOB",
89
+ help="require every changed path to match one of these globs "
90
+ "(tighter than --content-only); composes with it")
91
+ args = ap.parse_args(argv)
92
+
93
+ raw = [line for line in (args.paths if args.paths else sys.stdin.read().splitlines())
94
+ if line.strip()]
95
+ cats = []
96
+ for line in raw:
97
+ c = classify_one(line)
98
+ if c and c not in cats:
99
+ cats.append(c)
100
+
101
+ # When either gate is requested, ALL requested gates must pass (exit 0/1).
102
+ if args.content_only or args.allow_globs is not None:
103
+ ok = True
104
+ if args.content_only:
105
+ content_ok = bool(cats) and cats == ["content"]
106
+ if not content_ok:
107
+ others = [c for c in cats if c != "content"] or ["<empty diff>"]
108
+ print(f"not content-only: also touches {', '.join(others)}", file=sys.stderr)
109
+ ok = ok and content_ok
110
+ if args.allow_globs is not None:
111
+ offenders = [p.strip() for p in raw
112
+ if not any(fnmatch(p.strip(), g) for g in args.allow_globs)]
113
+ # An empty diff is never "allowed" — there is nothing legitimate to merge.
114
+ if not raw:
115
+ print("no changed paths — refusing (nothing to merge)", file=sys.stderr)
116
+ ok = False
117
+ elif offenders:
118
+ print(f"outside allowed scope ({', '.join(args.allow_globs)}): "
119
+ f"{', '.join(offenders)}", file=sys.stderr)
120
+ ok = False
121
+ return 0 if ok else 1
122
+
123
+ for c in cats:
124
+ print(c)
125
+ return 0
126
+
127
+
128
+ if __name__ == "__main__":
129
+ raise SystemExit(main())
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ dispatch.py — Issue Autopilot OODA controller / budget gate.
4
+
5
+ Decides what the autopilot should actually act on THIS run, clamped to human
6
+ review speed. It reads the plan produced by triage.py (.issues/plan.json) and the
7
+ backpressure caps (.issues/budget.yml), OBSERVES how many auto:issue PRs are
8
+ already open, ORIENTS the plan's batches into triage-only vs PR-opening, and
9
+ DECIDES how many resolution PRs may be proposed without burying the reviewer.
10
+
11
+ Loop (OODA):
12
+ OBSERVE count open PRs labeled `auto:issue` (via gh).
13
+ ORIENT split plan batches into triage (close/decompose/needs-human; no PR)
14
+ and resolve (resolve-content/resolve-code; opens a PR).
15
+ DECIDE if open auto:issue PRs >= caps.max_open_prs -> backlog_heavy: open
16
+ nothing. Else fill remaining slots, capped by
17
+ max_resolve_batches_per_run; the rest are deferred (with reasons).
18
+ ACT (elsewhere) — this script only emits the dispatch plan.
19
+
20
+ ---------------------------------------------------------------------------
21
+ READ / PLAN ONLY. This script NEVER mutates GitHub. It does not open, close,
22
+ comment on, or merge anything. It only reads (gh pr list) and writes
23
+ .issues/dispatch.json plus an optional GitHub Actions matrix line. The
24
+ workflow/agents ACT on this output.
25
+ ---------------------------------------------------------------------------
26
+
27
+ FAIL-SAFE: if plan.json is missing OR older than caps.plan_max_age_minutes,
28
+ the resolve list is empty, a ::warning:: is printed, and we exit 0.
29
+
30
+ Author: IT-Journey Team | Part of the Issue Autopilot foundation.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import json
36
+ import subprocess
37
+ import sys
38
+ from datetime import datetime, timezone
39
+ from pathlib import Path
40
+ from typing import Any, Optional
41
+
42
+ try:
43
+ import yaml
44
+ except ImportError: # pragma: no cover - environment guard
45
+ print("ERROR: PyYAML required. pip install pyyaml", file=sys.stderr)
46
+ sys.exit(1)
47
+
48
+ # --------------------------------------------------------------------------- #
49
+ # Paths
50
+ # --------------------------------------------------------------------------- #
51
+ SCRIPT_DIR = Path(__file__).resolve().parent
52
+ REPO_ROOT = SCRIPT_DIR.parent.parent
53
+ ISSUES_DIR = REPO_ROOT / ".issues"
54
+ DEFAULT_PLAN = ISSUES_DIR / "plan.json"
55
+ BUDGET_PATH = ISSUES_DIR / "budget.yml"
56
+ CONFIG_PATH = ISSUES_DIR / "config.yml"
57
+
58
+ # Actions that open a PR (resolve lane) vs triage-only (no new PR).
59
+ RESOLVE_ACTIONS = {"resolve-content", "resolve-code"}
60
+
61
+ # The PR label the autopilot owns (fallback if config is unreadable).
62
+ DEFAULT_AUTO_ISSUE_LABEL = "auto:issue"
63
+
64
+
65
+ # --------------------------------------------------------------------------- #
66
+ # Small utilities
67
+ # --------------------------------------------------------------------------- #
68
+ def warn(msg: str) -> None:
69
+ """Emit a GitHub-Actions-style warning that is harmless in a plain shell."""
70
+ print(f"::warning::{msg}", file=sys.stderr)
71
+
72
+
73
+ def now_iso() -> str:
74
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
75
+
76
+
77
+ def load_yaml(path: Path) -> dict[str, Any]:
78
+ """Load a YAML mapping with a clear error on parse failure."""
79
+ try:
80
+ with path.open("r", encoding="utf-8") as fh:
81
+ data = yaml.safe_load(fh) or {}
82
+ except FileNotFoundError:
83
+ raise
84
+ except yaml.YAMLError as exc:
85
+ print(f"ERROR: could not parse {path}: {exc}", file=sys.stderr)
86
+ raise
87
+ if not isinstance(data, dict):
88
+ raise ValueError(f"{path} root must be a mapping, got {type(data).__name__}")
89
+ return data
90
+
91
+
92
+ def load_plan(path: Path) -> Optional[dict[str, Any]]:
93
+ """Load plan.json. Returns None (with a warning) if missing/unparseable."""
94
+ try:
95
+ with path.open("r", encoding="utf-8") as fh:
96
+ data = json.load(fh)
97
+ except FileNotFoundError:
98
+ warn(f"plan not found at {path}; nothing to dispatch")
99
+ return None
100
+ except json.JSONDecodeError as exc:
101
+ warn(f"plan at {path} is not valid JSON ({exc}); nothing to dispatch")
102
+ return None
103
+ if not isinstance(data, dict):
104
+ warn(f"plan at {path} is not a JSON object; nothing to dispatch")
105
+ return None
106
+ return data
107
+
108
+
109
+ def auto_issue_label(config: Optional[dict[str, Any]]) -> str:
110
+ """Resolve the auto:issue PR label from config, with a safe default."""
111
+ if not config:
112
+ return DEFAULT_AUTO_ISSUE_LABEL
113
+ labels = config.get("labels") or {}
114
+ return str(labels.get("pr") or DEFAULT_AUTO_ISSUE_LABEL)
115
+
116
+
117
+ # --------------------------------------------------------------------------- #
118
+ # OBSERVE
119
+ # --------------------------------------------------------------------------- #
120
+ def count_open_auto_prs(repo: str, label: str) -> Optional[int]:
121
+ """
122
+ Count currently-open PRs carrying the auto:issue label. Returns None if gh is
123
+ unavailable/errors (caller treats None as "unknown" and stays conservative).
124
+ """
125
+ cmd = [
126
+ "gh", "pr", "list",
127
+ "--repo", repo,
128
+ "--state", "open",
129
+ "--label", label,
130
+ "--json", "number",
131
+ ]
132
+ try:
133
+ proc = subprocess.run(cmd, capture_output=True, text=True)
134
+ except (OSError, FileNotFoundError) as exc:
135
+ warn(f"gh unavailable counting open PRs ({exc}); treating as unknown")
136
+ return None
137
+ if proc.returncode != 0:
138
+ warn(
139
+ f"gh pr list exited {proc.returncode}: "
140
+ f"{proc.stderr.strip() or '(no stderr)'}; treating as unknown"
141
+ )
142
+ return None
143
+ try:
144
+ prs = json.loads(proc.stdout or "[]")
145
+ except json.JSONDecodeError as exc:
146
+ warn(f"could not parse gh pr list JSON ({exc}); treating as unknown")
147
+ return None
148
+ if not isinstance(prs, list):
149
+ return None
150
+ return len(prs)
151
+
152
+
153
+ # --------------------------------------------------------------------------- #
154
+ # ORIENT
155
+ # --------------------------------------------------------------------------- #
156
+ def orient_batches(
157
+ plan: dict[str, Any],
158
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
159
+ """
160
+ Split plan batches into (triage_batches, resolve_batches).
161
+
162
+ triage = close-* / decompose / needs-human (no new PR).
163
+ resolve = resolve-content / resolve-code (opens a PR).
164
+ Already-deferred resolve batches stay in resolve so the dispatcher can
165
+ re-evaluate them, but they are eligible for re-deferral below.
166
+ """
167
+ triage: list[dict[str, Any]] = []
168
+ resolve: list[dict[str, Any]] = []
169
+ for batch in plan.get("batches") or []:
170
+ if not isinstance(batch, dict):
171
+ continue
172
+ if batch.get("action") in RESOLVE_ACTIONS:
173
+ resolve.append(batch)
174
+ else:
175
+ triage.append(batch)
176
+ return triage, resolve
177
+
178
+
179
+ # --------------------------------------------------------------------------- #
180
+ # DECIDE
181
+ # --------------------------------------------------------------------------- #
182
+ def plan_is_stale(plan: dict[str, Any], max_age_minutes: int) -> bool:
183
+ """True if plan.generated is older than max_age_minutes (or unparseable)."""
184
+ if not max_age_minutes:
185
+ return False
186
+ generated = plan.get("generated")
187
+ if not generated:
188
+ warn("plan has no `generated` timestamp; treating as stale")
189
+ return True
190
+ raw = str(generated)
191
+ if raw.endswith("Z"): # normalize a 'Z' suffix (fromisoformat pre-3.11 can't)
192
+ raw = raw[:-1] + "+00:00"
193
+ try:
194
+ ts = datetime.fromisoformat(raw)
195
+ except ValueError:
196
+ warn(f"plan `generated` ({generated!r}) is unparseable; treating as stale")
197
+ return True
198
+ if ts.tzinfo is None:
199
+ ts = ts.replace(tzinfo=timezone.utc)
200
+ age_minutes = (datetime.now(timezone.utc) - ts).total_seconds() / 60.0
201
+ return age_minutes > max_age_minutes
202
+
203
+
204
+ def decide(
205
+ plan: dict[str, Any],
206
+ budget: dict[str, Any],
207
+ open_prs: Optional[int],
208
+ ) -> dict[str, Any]:
209
+ """
210
+ Core OODA decision. Returns the dispatch dict (without `generated`/matrix).
211
+ Never raises on policy; degrades to an empty resolve list when in doubt.
212
+ """
213
+ caps = budget.get("caps") or {}
214
+ max_open_prs = int(caps.get("max_open_prs", 5) or 0)
215
+ max_resolve = int(caps.get("max_resolve_batches_per_run", 3) or 0)
216
+
217
+ triage_batches, resolve_batches = orient_batches(plan)
218
+ triage_ids = [b.get("id") for b in triage_batches]
219
+
220
+ deferred: list[dict[str, Any]] = []
221
+
222
+ # If we couldn't observe the PR queue, stay conservative: open nothing.
223
+ if open_prs is None:
224
+ warn("open auto:issue PR count unknown; opening no resolution PRs this run")
225
+ for b in resolve_batches:
226
+ deferred.append({
227
+ "id": b.get("id"),
228
+ "reason": "open-PR count unknown (gh unavailable); conservative no-op",
229
+ })
230
+ return {
231
+ "open_auto_issue_prs": None,
232
+ "triage": triage_ids,
233
+ "resolve": [],
234
+ "deferred": deferred,
235
+ }
236
+
237
+ # Backlog-heavy backpressure: at/over cap → open nothing new.
238
+ if max_open_prs and open_prs >= max_open_prs:
239
+ warn(
240
+ f"backlog_heavy: {open_prs} open auto:issue PR(s) >= "
241
+ f"max_open_prs={max_open_prs}; opening no new resolution PRs"
242
+ )
243
+ for b in resolve_batches:
244
+ deferred.append({
245
+ "id": b.get("id"),
246
+ "reason": (
247
+ f"backlog_heavy: {open_prs} open auto:issue PRs "
248
+ f">= max_open_prs={max_open_prs}"
249
+ ),
250
+ })
251
+ return {
252
+ "open_auto_issue_prs": open_prs,
253
+ "triage": triage_ids,
254
+ "resolve": [],
255
+ "deferred": deferred,
256
+ }
257
+
258
+ # Normal: fill remaining slots, capped by max_resolve_batches_per_run.
259
+ # max_open_prs == 0 means "no PR-backpressure limit" (consistent with
260
+ # max_resolve == 0 meaning "no per-run cap") rather than "open nothing".
261
+ available_slots = (max(0, max_open_prs - open_prs) if max_open_prs
262
+ else len(resolve_batches))
263
+ take = min(available_slots, max_resolve, len(resolve_batches)) if max_resolve \
264
+ else min(available_slots, len(resolve_batches))
265
+
266
+ resolve_selected = resolve_batches[:take]
267
+ for idx, b in enumerate(resolve_batches[take:], start=take):
268
+ if idx >= available_slots:
269
+ reason = (
270
+ f"no free slot: available_slots="
271
+ f"{available_slots} (max_open_prs={max_open_prs} - "
272
+ f"open={open_prs})"
273
+ )
274
+ else:
275
+ reason = (
276
+ f"exceeds max_resolve_batches_per_run={max_resolve} this run"
277
+ )
278
+ deferred.append({"id": b.get("id"), "reason": reason})
279
+
280
+ if deferred:
281
+ warn(
282
+ f"deferred {len(deferred)} resolve batch(es) this run "
283
+ f"(open={open_prs}, slots={available_slots}, cap={max_resolve})"
284
+ )
285
+
286
+ return {
287
+ "open_auto_issue_prs": open_prs,
288
+ "triage": triage_ids,
289
+ "resolve": resolve_selected,
290
+ "deferred": deferred,
291
+ }
292
+
293
+
294
+ # --------------------------------------------------------------------------- #
295
+ # EMIT
296
+ # --------------------------------------------------------------------------- #
297
+ def build_matrix(resolve_batches: list[dict[str, Any]]) -> dict[str, Any]:
298
+ """
299
+ Build a GitHub Actions matrix include-list for the selected resolve batches.
300
+ issue_numbers is a comma-joined string so each matrix entry is a clean scalar.
301
+ Always returns a valid object; empty selection -> {"include": []}.
302
+ """
303
+ include: list[dict[str, Any]] = []
304
+ for b in resolve_batches:
305
+ numbers = b.get("issue_numbers") or []
306
+ include.append({
307
+ "batch_id": b.get("id"),
308
+ "action": b.get("action"),
309
+ "area": b.get("area"),
310
+ "issue_numbers": ",".join(str(n) for n in numbers),
311
+ })
312
+ return {"include": include}
313
+
314
+
315
+ def append_github_output(path: Path, matrix: dict[str, Any]) -> None:
316
+ """Append `matrix=<json>` to the $GITHUB_OUTPUT-style file."""
317
+ line = "matrix=" + json.dumps(matrix, ensure_ascii=False, separators=(",", ":"))
318
+ path.parent.mkdir(parents=True, exist_ok=True)
319
+ with path.open("a", encoding="utf-8") as fh:
320
+ fh.write(line + "\n")
321
+
322
+
323
+ def write_dispatch(path: Path, data: dict[str, Any]) -> None:
324
+ path.parent.mkdir(parents=True, exist_ok=True)
325
+ with path.open("w", encoding="utf-8") as fh:
326
+ json.dump(data, fh, indent=2, ensure_ascii=False)
327
+ fh.write("\n")
328
+
329
+
330
+ # --------------------------------------------------------------------------- #
331
+ # Main flow
332
+ # --------------------------------------------------------------------------- #
333
+ def run(args: argparse.Namespace) -> int:
334
+ # Config (only used to resolve the auto:issue label + repo fallback).
335
+ config: Optional[dict[str, Any]] = None
336
+ try:
337
+ config = load_yaml(CONFIG_PATH)
338
+ except (FileNotFoundError, ValueError, yaml.YAMLError):
339
+ warn(f"config unreadable at {CONFIG_PATH}; using defaults")
340
+
341
+ # Budget caps.
342
+ try:
343
+ budget = load_yaml(BUDGET_PATH)
344
+ except FileNotFoundError:
345
+ print(f"ERROR: budget not found at {BUDGET_PATH}", file=sys.stderr)
346
+ return 1
347
+ except (ValueError, yaml.YAMLError):
348
+ return 1
349
+
350
+ caps = budget.get("caps") or {}
351
+ max_age = int(caps.get("plan_max_age_minutes", 0) or 0)
352
+
353
+ repo = args.repo or (config.get("repo") if config else None) or ""
354
+ label = auto_issue_label(config)
355
+
356
+ plan_path = Path(args.plan) if args.plan else DEFAULT_PLAN
357
+ plan = load_plan(plan_path)
358
+
359
+ # Always emit a valid (possibly empty) matrix so the workflow never errors.
360
+ empty_matrix = {"include": []}
361
+
362
+ # FAIL-SAFE: missing plan -> empty resolve, exit 0.
363
+ if plan is None:
364
+ dispatch = {
365
+ "generated": now_iso(),
366
+ "open_auto_issue_prs": None,
367
+ "triage": [],
368
+ "resolve": [],
369
+ "deferred": [{"id": None, "reason": "plan.json missing/unreadable"}],
370
+ }
371
+ _emit(args, dispatch, empty_matrix)
372
+ return 0
373
+
374
+ # FAIL-SAFE: stale plan -> empty resolve, exit 0.
375
+ if plan_is_stale(plan, max_age):
376
+ warn(
377
+ f"plan is older than caps.plan_max_age_minutes={max_age}; "
378
+ f"opening no resolution PRs this run"
379
+ )
380
+ triage_batches, resolve_batches = orient_batches(plan)
381
+ dispatch = {
382
+ "generated": now_iso(),
383
+ "open_auto_issue_prs": None,
384
+ "triage": [b.get("id") for b in triage_batches],
385
+ "resolve": [],
386
+ "deferred": [
387
+ {"id": b.get("id"), "reason": "plan stale (exceeds plan_max_age_minutes)"}
388
+ for b in resolve_batches
389
+ ],
390
+ }
391
+ _emit(args, dispatch, empty_matrix)
392
+ return 0
393
+
394
+ if not repo:
395
+ warn("no repo (args/config); cannot observe PR queue — staying conservative")
396
+ open_prs = None
397
+ else:
398
+ open_prs = count_open_auto_prs(repo, label)
399
+
400
+ decision = decide(plan, budget, open_prs)
401
+ dispatch = {"generated": now_iso(), **decision}
402
+ matrix = build_matrix(decision["resolve"])
403
+ _emit(args, dispatch, matrix)
404
+ return 0
405
+
406
+
407
+ def _emit(args: argparse.Namespace, dispatch: dict[str, Any],
408
+ matrix: dict[str, Any]) -> None:
409
+ """Print + (unless dry-run) write dispatch.json and the matrix line."""
410
+ n_resolve = len(dispatch.get("resolve") or [])
411
+ n_triage = len(dispatch.get("triage") or [])
412
+ n_deferred = len(dispatch.get("deferred") or [])
413
+ open_prs = dispatch.get("open_auto_issue_prs")
414
+
415
+ print("Issue Autopilot — dispatch")
416
+ print(f" open auto:issue PRs: {open_prs}")
417
+ print(f" triage batches: {n_triage}")
418
+ print(f" resolve (open PRs): {n_resolve}")
419
+ print(f" deferred: {n_deferred}")
420
+ for b in dispatch.get("resolve") or []:
421
+ print(f" -> {b.get('id')} ({b.get('action')}) issues={b.get('issue_numbers')}")
422
+ for d in dispatch.get("deferred") or []:
423
+ print(f" ~ deferred {d.get('id')}: {d.get('reason')}")
424
+
425
+ if args.dry_run:
426
+ print("\n--- dispatch.json (dry-run, not written) ---")
427
+ print(json.dumps(dispatch, indent=2, ensure_ascii=False))
428
+ print("\n--- matrix (dry-run) ---")
429
+ print("matrix=" + json.dumps(matrix, ensure_ascii=False, separators=(",", ":")))
430
+ return
431
+
432
+ out_path = ISSUES_DIR / "dispatch.json"
433
+ write_dispatch(out_path, dispatch)
434
+ print(f"Wrote {out_path}")
435
+
436
+ if args.github_output:
437
+ append_github_output(Path(args.github_output), matrix)
438
+ print(f"Appended matrix to {args.github_output}")
439
+
440
+
441
+ # --------------------------------------------------------------------------- #
442
+ # CLI
443
+ # --------------------------------------------------------------------------- #
444
+ def build_parser() -> argparse.ArgumentParser:
445
+ parser = argparse.ArgumentParser(
446
+ prog="dispatch.py",
447
+ description=(
448
+ "Issue Autopilot OODA controller / budget gate. READ + PLAN ONLY — "
449
+ "never mutates GitHub. Decides which resolve batches may open a PR "
450
+ "this run without burying the reviewer, and emits dispatch.json."
451
+ ),
452
+ )
453
+ parser.add_argument(
454
+ "--repo", default=None, help="owner/name (default: config.repo)"
455
+ )
456
+ parser.add_argument(
457
+ "--plan", default=None,
458
+ help="path to plan.json (default: .issues/plan.json)",
459
+ )
460
+ parser.add_argument(
461
+ "--github-output", default=None,
462
+ help="path of a $GITHUB_OUTPUT-style file to append `matrix=<json>` to",
463
+ )
464
+ parser.add_argument(
465
+ "--dry-run", action="store_true",
466
+ help="compute and print the decision but write nothing",
467
+ )
468
+ return parser
469
+
470
+
471
+ def main(argv: Optional[list[str]] = None) -> int:
472
+ parser = build_parser()
473
+ args = parser.parse_args(argv)
474
+ return run(args)
475
+
476
+
477
+ if __name__ == "__main__":
478
+ sys.exit(main())