loopagent-gate 0.4.2__py3-none-any.whl

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. loopagent_gate-0.4.2.dist-info/METADATA +374 -0
  2. loopagent_gate-0.4.2.dist-info/RECORD +40 -0
  3. loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
  4. loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
  5. loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
  6. loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
  7. loopgate/__init__.py +7 -0
  8. loopgate/__main__.py +8 -0
  9. loopgate/cli.py +147 -0
  10. loopgate/gates/anchor_witness.py +249 -0
  11. loopgate/gates/assert_dims.py +88 -0
  12. loopgate/gates/bootstrap_state.py +111 -0
  13. loopgate/gates/change_health.py +232 -0
  14. loopgate/gates/check_loop.py +1267 -0
  15. loopgate/gates/check_product.py +463 -0
  16. loopgate/gates/check_visual.py +224 -0
  17. loopgate/gates/doctor.py +130 -0
  18. loopgate/gates/estimate_task.py +137 -0
  19. loopgate/gates/export_public.py +289 -0
  20. loopgate/gates/guard_card.py +519 -0
  21. loopgate/gates/loop_watch.py +118 -0
  22. loopgate/gates/regressions.py +463 -0
  23. loopgate/gates/render_status_page.py +151 -0
  24. loopgate/gates/render_task_ledger.py +312 -0
  25. loopgate/gates/review_pr.py +370 -0
  26. loopgate/gates/sync_issues_to_kanban.py +593 -0
  27. loopgate/gates/tree_parity.py +307 -0
  28. loopgate/gates/worker_brief.py +396 -0
  29. loopgate/init.py +128 -0
  30. loopgate/paths.py +43 -0
  31. loopgate/py.typed +0 -0
  32. loopgate/templates/__init__.py +1 -0
  33. loopgate/templates/configs/anchor_witness.example.json +14 -0
  34. loopgate/templates/configs/fixtures/issues_sample.json +75 -0
  35. loopgate/templates/configs/fixtures/review_approve.json +5 -0
  36. loopgate/templates/configs/fixtures/review_changes.json +5 -0
  37. loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
  38. loopgate/templates/configs/repo_profiles.example.json +63 -0
  39. loopgate/templates/configs/tree_parity.example.json +16 -0
  40. loopgate/templates/skills/loop-engineering/SKILL.md +281 -0
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env python3
2
+ """Issue bridge — GitHub issues -> Hermes kanban execution queue (PLANS.md stage 6).
3
+
4
+ Roles of the two sides
5
+ ----------------------
6
+ * GitHub issues are the **source of truth** and the external feedback channel (testers, operator, chief).
7
+ * The Hermes kanban board is the **local execution queue**: tasks are claimed atomically and executed by
8
+ a named profile in an isolated workspace. The bridge is the only path from one to the other.
9
+
10
+ What the bridge guarantees
11
+ --------------------------
12
+ * **One task = one PR**: one open issue produces exactly one kanban task, whose body is the worker brief
13
+ rendered by ``loopgate/gates/worker_brief.py`` (mandatory evidence bundle included, not implied).
14
+ * **Idempotence**: the task carries the issue-derived idempotency key ``<exporter>-<owner>-<repo>-<n>``;
15
+ a second run reports the task as already queued instead of minting a duplicate. The exporter prefix is
16
+ part of the key, so a file export can never collide with a live ``gh`` key space it does not own.
17
+ * **Fail-closed**: an issue without acceptance criteria is *not* queued (it is reported as needing
18
+ triage), an unknown repository profile is refused, and ``--apply`` refuses to queue work that no
19
+ worker could execute (no workspace/project target configured) — a queue that cannot run is a lie.
20
+ * **Read-only by default**: mutations happen only with ``--apply``. ``--issues-file`` is a *fixture*
21
+ unless whoever exported it says otherwise with ``--exported-by <name>``; an undeclared file can only be
22
+ printed, never applied. A declared file is authoritative for the exporter it names: its keys carry that
23
+ name, ``--target backlog`` is allowed for any exporter, and ``--target kanban`` stays restricted to
24
+ ``gh`` exports (a queue that starts unattended workers is a claim about real issue numbers).
25
+
26
+ Left to the triage role (stage 5): the semantic half of validity — whether the reported behaviour is
27
+ reproducible at all. The bridge enforces only the machine-checkable subset (labels, body substance,
28
+ acceptance criteria) and says so in its report.
29
+
30
+ Usage
31
+ -----
32
+ python3 -m loopgate.gates.sync_issues_to_kanban --profile sample-service # dry run, live gh
33
+ python3 -m loopgate.gates.sync_issues_to_kanban --profile sample-service --issues-file configs/fixtures/issues_sample.json
34
+ python3 -m loopgate.gates.sync_issues_to_kanban --profile sample-service --issues-file issues.json \
35
+ --exported-by gh --apply --target backlog # a file that IS the source of truth
36
+ python3 -m loopgate.gates.sync_issues_to_kanban --profile sample-service --apply # queue for real
37
+ python3 -m loopgate.gates.sync_issues_to_kanban --profile sample-service --selftest # prove the write path
38
+
39
+ Exit 0 prints ``ISSUE_BRIDGE_PASS_0001``; a failure prints ``ISSUE_BRIDGE_STOP_0001`` and exits 1.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from loopgate.paths import project_root, gate_file
45
+
46
+ import argparse
47
+ import datetime as dt
48
+ import json
49
+ import os
50
+ import re
51
+ import subprocess
52
+ import sys
53
+
54
+ ROOT = project_root()
55
+
56
+ from loopgate.gates import worker_brief # noqa: E402 (sibling script: the canonical brief + validator)
57
+
58
+ PASS_MARKER = "ISSUE_BRIDGE_PASS_0001"
59
+ STOP_MARKER = "ISSUE_BRIDGE_STOP_0001"
60
+ REPORT_REL = "artifacts/issue_bridge_report.json"
61
+ SELFTEST_KEY = "issue-bridge-selftest"
62
+ SELFTEST_TITLE = "[issue-bridge selftest] write path check"
63
+ MIN_BODY_CHARS = 120
64
+ TITLE_LIMIT = 110
65
+ GH_FIELDS = "number,title,body,labels,url,state"
66
+ ACCEPTANCE_RE = re.compile(
67
+ r"^\s*#{1,4}\s*(acceptance|acceptance criteria|критерии приёмки)\b"
68
+ r"|^\s*\**acceptance criteria\**\s*:",
69
+ re.I | re.M,
70
+ )
71
+
72
+
73
+ # ---------------------------------------------------------------- sources
74
+
75
+ def gh_issues(repo: str, limit: int) -> list[dict]:
76
+ proc = subprocess.run(
77
+ ["gh", "issue", "list", "-R", repo, "--state", "open", "--limit", str(limit),
78
+ "--json", GH_FIELDS],
79
+ capture_output=True, text=True,
80
+ )
81
+ if proc.returncode != 0:
82
+ raise RuntimeError(f"gh issue list failed ({proc.returncode}): {proc.stderr.strip()}")
83
+ return [normalize_issue(i) for i in json.loads(proc.stdout or "[]")]
84
+
85
+
86
+ def fixture_issues(path: str) -> list[dict]:
87
+ with open(path, encoding="utf-8") as fh:
88
+ payload = json.load(fh)
89
+ issues = payload["issues"] if isinstance(payload, dict) else payload
90
+ return [normalize_issue(i) for i in issues]
91
+
92
+
93
+ def normalize_issue(raw: dict) -> dict:
94
+ labels = raw.get("labels") or []
95
+ names = [l.get("name", "") if isinstance(l, dict) else str(l) for l in labels]
96
+ return {
97
+ "number": int(raw["number"]),
98
+ "title": (raw.get("title") or "").strip(),
99
+ "body": raw.get("body") or "",
100
+ "url": raw.get("url") or "",
101
+ "labels": [n for n in names if n],
102
+ }
103
+
104
+
105
+ # ---------------------------------------------------------------- triage (machine-checkable subset)
106
+
107
+ def classify(issue: dict, profile: dict) -> tuple[str, str]:
108
+ """Return ('queue' | 'needs-triage' | 'skip', reason).
109
+
110
+ The reason names the *basis* of the verdict, because the two queue paths are not equally strong: an
111
+ acceptance-criteria section is evidence in the issue itself, while a triage label is the triage
112
+ role's semantic approval — the brief then requires the worker to state the concrete criteria it
113
+ implemented against.
114
+ """
115
+ labels = {l.lower() for l in issue.get("labels") or []}
116
+ skipped = labels & {str(l).lower() for l in profile.get("skip_labels") or []}
117
+ if skipped:
118
+ return "skip", f"label(s) {', '.join(sorted(skipped))}: not work for this queue"
119
+ body = (issue.get("body") or "").strip()
120
+ if len(body) < MIN_BODY_CHARS:
121
+ return "needs-triage", (f"body is {len(body)} chars (< {MIN_BODY_CHARS}): nothing to reproduce "
122
+ "or to accept")
123
+ triaged = sorted(labels & {str(l).lower() for l in profile.get("triage_labels") or []})
124
+ if ACCEPTANCE_RE.search(body):
125
+ return "queue", "acceptance-criteria section present in the issue"
126
+ if triaged:
127
+ return "queue", (f"triage label {', '.join(triaged)} (triage role approved; the brief requires the "
128
+ "worker to state the concrete criteria)")
129
+ return "needs-triage", "no acceptance-criteria section and no triage label"
130
+
131
+
132
+ # ---------------------------------------------------------------- kanban queue
133
+
134
+ ESTIMATES_REL = "configs/task_estimates.json"
135
+
136
+
137
+ EXPORTER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
138
+ # Names that would put a file's keys inside a key space this bridge already reserves. `fixture` is the
139
+ # unapplied source, and `selftest` belongs to the write-path check; neither may become authoritative,
140
+ # otherwise the guard that stops a fixture from shadowing real issue numbers would be removable by a flag.
141
+ RESERVED_EXPORTERS = {"fixture", "selftest", "dry-run", "none"}
142
+
143
+
144
+ def issue_key(profile: dict, number: int, exporter: str = "gh") -> str:
145
+ """Idempotency key: the exporter is part of the identity, not an afterthought.
146
+
147
+ A `gh` export and the live `gh` path share the `gh-` prefix on purpose — the same issue queued from a
148
+ file and from the CLI must resolve to one task. Any other exporter gets its own prefix, so a Jira or
149
+ spreadsheet export can never shadow a real GitHub issue number.
150
+ """
151
+ return f"{exporter}-" + profile["repo"].replace("/", "-") + f"-{number}"
152
+
153
+
154
+ def exporter_error(name: str) -> str | None:
155
+ """Why this name cannot declare a file authoritative, or None when it can."""
156
+ slug = (name or "").strip().lower()
157
+ if not slug:
158
+ return "empty name"
159
+ if slug in RESERVED_EXPORTERS:
160
+ return (f"`{slug}` is reserved for the unapplied fixture source; declaring it authoritative would "
161
+ f"put a file's keys in a key space the bridge already owns")
162
+ if not EXPORTER_RE.fullmatch(slug):
163
+ return f"`{name}` is not a usable exporter name (expected lowercase letters, digits and dashes)"
164
+ return None
165
+
166
+
167
+ def load_estimates(root: str = ROOT) -> dict:
168
+ """Per-assignee timeboxes measured from queue history (loopgate/gates/estimate_task.py writes this file)."""
169
+ try:
170
+ with open(os.path.join(root, ESTIMATES_REL), encoding="utf-8") as fh:
171
+ return json.load(fh).get("assignees") or {}
172
+ except (OSError, json.JSONDecodeError):
173
+ return {}
174
+
175
+
176
+ def assignee_for(issue: dict, profile: dict) -> str:
177
+ """A role label wins over the profile default: 'qa' issues go to the qa profile, everything else to
178
+ the profile's implementer. One issue, one owner."""
179
+ kb = profile.get("kanban") or {}
180
+ labels = {l.lower() for l in issue.get("labels") or []}
181
+ for label, assignee in (kb.get("assignee_by_label") or {}).items():
182
+ if str(label).lower() in labels:
183
+ return assignee
184
+ return kb.get("assignee") or ""
185
+
186
+
187
+ def task_title(profile_name: str, issue: dict) -> str:
188
+ head = f"[{profile_name}#{issue['number']}] {issue['title']}"
189
+ return head if len(head) <= TITLE_LIMIT else head[:TITLE_LIMIT - 1].rstrip() + "…"
190
+
191
+
192
+ def existing_tasks() -> list[dict]:
193
+ proc = subprocess.run(["hermes", "kanban", "list", "--json"], capture_output=True, text=True)
194
+ if proc.returncode != 0:
195
+ raise RuntimeError(f"hermes kanban list failed ({proc.returncode}): {proc.stderr.strip()}")
196
+ return json.loads(proc.stdout or "[]")
197
+
198
+
199
+ def queued_numbers(tasks: list[dict], profile_name: str) -> dict[int, str]:
200
+ """Map issue number -> task id for tasks this bridge already created."""
201
+ prefix = f"[{profile_name}#"
202
+ out: dict[int, str] = {}
203
+ for task in tasks:
204
+ title = task.get("title") or ""
205
+ m = re.match(re.escape(prefix) + r"(\d+)\]", title)
206
+ if m:
207
+ out[int(m.group(1))] = task.get("id", "?")
208
+ return out
209
+
210
+
211
+ def kanban_create(title: str, brief_path: str, profile: dict, key: str,
212
+ initial_status: str | None = None, assignee: str | None = None,
213
+ max_runtime: str | None = None) -> dict:
214
+ kb = profile.get("kanban") or {}
215
+ cmd = ["hermes", "kanban", "create", title, "--body-file", brief_path,
216
+ "--idempotency-key", key, "--created-by", "issue-bridge", "--json"]
217
+ assignee = assignee or kb.get("assignee")
218
+ if assignee:
219
+ cmd += ["--assignee", assignee]
220
+ if max_runtime:
221
+ cmd += ["--max-runtime", max_runtime]
222
+ if kb.get("project"):
223
+ cmd += ["--project", kb["project"]]
224
+ if kb.get("workspace"):
225
+ cmd += ["--workspace", kb["workspace"]]
226
+ if kb.get("priority") is not None:
227
+ cmd += ["--priority", str(kb["priority"])]
228
+ if initial_status:
229
+ cmd += ["--initial-status", initial_status]
230
+ proc = subprocess.run(cmd, capture_output=True, text=True)
231
+ if proc.returncode != 0:
232
+ raise RuntimeError(f"hermes kanban create failed ({proc.returncode}): {proc.stderr.strip()}")
233
+ try:
234
+ return json.loads(proc.stdout)
235
+ except json.JSONDecodeError:
236
+ return {"raw": proc.stdout.strip()}
237
+
238
+
239
+ def backlog_dir(root: str) -> str:
240
+ return os.path.join(root, "backlog")
241
+
242
+
243
+ def backlog_existing(root: str, profile_name: str) -> dict[int, str]:
244
+ """Issue number -> ledger file name, for cards this bridge already wrote as markdown."""
245
+ prefix = f"[{profile_name}#"
246
+ out: dict[int, str] = {}
247
+ directory = backlog_dir(root)
248
+ if not os.path.isdir(directory):
249
+ return out
250
+ for name in sorted(os.listdir(directory)):
251
+ if not name.endswith(".md"):
252
+ continue
253
+ try:
254
+ with open(os.path.join(directory, name), encoding="utf-8") as fh:
255
+ head = fh.readline()
256
+ except OSError:
257
+ continue
258
+ m = re.search(re.escape(prefix) + r"(\d+)\]", head)
259
+ if m:
260
+ out[int(m.group(1))] = name
261
+ return out
262
+
263
+
264
+ def backlog_write(root: str, title: str, brief: str, profile: dict, key: str,
265
+ assignee: str | None, max_runtime: str | None) -> dict:
266
+ """Write the card as a markdown ledger file: the queue works without any execution backend.
267
+
268
+ `--target kanban` hands the card to a dispatcher that runs it; `--target backlog` only records it,
269
+ so a clone without Hermes still turns issues into reviewable, versioned task specs.
270
+ """
271
+ directory = backlog_dir(root)
272
+ os.makedirs(directory, exist_ok=True)
273
+ path = os.path.join(directory, f"{key}.md")
274
+ number = key.rsplit("-", 1)[-1]
275
+ header = (f"# {title}\n\n"
276
+ f"- issue: {profile['repo']}#{number}\n"
277
+ f"- assignee: {assignee or '(unset)'}\n"
278
+ f"- max-runtime: {max_runtime or '(none)'}\n"
279
+ f"- idempotency-key: {key}\n"
280
+ f"- status: queued\n"
281
+ f"- queued-at: {dt.datetime.now().astimezone().isoformat(timespec='seconds')}\n\n---\n\n")
282
+ with open(path, "w", encoding="utf-8") as fh:
283
+ fh.write(header + brief)
284
+ return {"id": os.path.relpath(path, root), "status": "queued"}
285
+
286
+
287
+ AUTONOMY_REL = "configs/autonomy.json"
288
+
289
+
290
+ def autonomy_config(root: str) -> dict:
291
+ """The ladder between a human placing every card and the machine placing them overnight.
292
+
293
+ Default is the safest rung, not the most convenient one: a missing or unreadable config means HITL,
294
+ because a bridge that starts queueing work nobody approved is worse than a bridge that asks.
295
+ """
296
+ path = os.path.join(root, AUTONOMY_REL)
297
+ try:
298
+ with open(path, encoding="utf-8") as fh:
299
+ cfg = json.load(fh)
300
+ except (OSError, json.JSONDecodeError):
301
+ return {"mode": "hitl", "max_cards": 0, "budget_tokens": 0, "source": f"{AUTONOMY_REL} missing or invalid"}
302
+ cfg = cfg if isinstance(cfg, dict) else {}
303
+ return {"mode": str(cfg.get("mode") or "hitl").lower(),
304
+ "max_cards": cfg.get("max_cards"),
305
+ "budget_tokens": cfg.get("budget_tokens"),
306
+ "active": cfg.get("active"),
307
+ "source": AUTONOMY_REL}
308
+
309
+
310
+ def autonomy_refusal(root: str, target: str) -> str | None:
311
+ """Why --apply is not allowed right now, or None when it is.
312
+
313
+ `backlog` writes a ledger line and starts nothing, so it is allowed at every rung; `kanban` starts a
314
+ worker, and that is what a human has to authorise — or what an AFK config has to bound in advance.
315
+ """
316
+ if target != "kanban":
317
+ return None
318
+ cfg = autonomy_config(root)
319
+ mode = cfg["mode"]
320
+ if mode == "hitl":
321
+ return ("autonomy is HITL: every card is placed by a human (`hermes kanban create` or the Telegram "
322
+ "gateway). Set mode=afk in configs/autonomy.json with a budget to let the bridge queue work")
323
+ if mode != "afk":
324
+ return f"unknown autonomy mode {mode!r} in configs/autonomy.json (expected 'hitl' or 'afk')"
325
+ budget = cfg["budget_tokens"]
326
+ cards = cfg["max_cards"]
327
+ if not isinstance(budget, int) or isinstance(budget, bool) or budget <= 0:
328
+ return ("afk mode without a token budget: configs/autonomy.json needs budget_tokens > 0, otherwise "
329
+ "an unattended bridge can spend without a ceiling")
330
+ if not isinstance(cards, int) or isinstance(cards, bool) or cards < 1:
331
+ return ("afk mode without max_cards: configs/autonomy.json needs max_cards >= 1, otherwise one poll "
332
+ "can queue the whole issue tracker")
333
+ return None
334
+
335
+
336
+ def apply_target_error(profile: dict) -> str | None:
337
+ """Refuse to queue work no worker could execute (fail-closed, stage-6 prerequisite)."""
338
+ kb = profile.get("kanban") or {}
339
+ if kb.get("project") or kb.get("workspace"):
340
+ return None
341
+ return ("profile has neither kanban.project nor kanban.workspace: register the target repo as a "
342
+ "Hermes project (`hermes project create`) or set kanban.workspace, otherwise the dispatched "
343
+ "worker gets an empty scratch workspace and the brief's own stop-if fires")
344
+
345
+
346
+ # ---------------------------------------------------------------- selftest
347
+
348
+ def selftest(profile: dict) -> int:
349
+ """Prove the queue write path live: create (blocked) -> show -> archive."""
350
+ brief = "# issue-bridge selftest\n\nNo worker action required; this task exists to prove the bridge's\n" \
351
+ "create/show/archive path and the idempotency key.\n"
352
+ tmp = os.path.join(ROOT, "artifacts", "_issue_bridge_selftest.md")
353
+ os.makedirs(os.path.dirname(tmp), exist_ok=True)
354
+ with open(tmp, "w", encoding="utf-8") as fh:
355
+ fh.write(brief)
356
+ created = kanban_create(SELFTEST_TITLE, tmp, profile, SELFTEST_KEY, initial_status="blocked")
357
+ task_id = created.get("id")
358
+ if not task_id:
359
+ print(f"selftest: create returned no task id: {created}")
360
+ return 1
361
+ show = subprocess.run(["hermes", "kanban", "show", task_id, "--json"],
362
+ capture_output=True, text=True)
363
+ if show.returncode != 0:
364
+ print(f"selftest: kanban show failed: {show.stderr.strip()}")
365
+ return 1
366
+ shown = json.loads(show.stdout)
367
+ task = shown.get("task", shown)
368
+ reuse = kanban_create(SELFTEST_TITLE, tmp, profile, SELFTEST_KEY, initial_status="blocked")
369
+ same = reuse.get("id") == task_id
370
+ print(f"selftest: id={task_id} status={task.get('status')} assignee={task.get('assignee')} "
371
+ f"created_by={task.get('created_by')} body_len={len(task.get('body') or '')}")
372
+ print(f"selftest: second create with the same idempotency key returned {reuse.get('id')} "
373
+ f"(same task: {same})")
374
+ archive = subprocess.run(["hermes", "kanban", "archive", task_id],
375
+ capture_output=True, text=True)
376
+ archived = archive.returncode == 0
377
+ print(f"selftest: archived={archived}")
378
+ os.remove(tmp)
379
+ return 0 if (same and archived and task.get("created_by") == "issue-bridge") else 1
380
+
381
+
382
+ # ---------------------------------------------------------------- main
383
+
384
+ def main(argv=None) -> int:
385
+ ap = argparse.ArgumentParser(description="Bridge GitHub issues into the Hermes kanban queue.")
386
+ ap.add_argument("--profile", required=True, help="repository profile from configs/repo_profiles.json")
387
+ ap.add_argument("--root", default=ROOT)
388
+ ap.add_argument("--limit", type=int, default=30)
389
+ ap.add_argument("--issues-file", help="read issues from a JSON file instead of the GitHub CLI; a file "
390
+ "is a fixture (print-only) until --exported-by marks it as the "
391
+ "authoritative export of a named tracker")
392
+ ap.add_argument("--exported-by", metavar="NAME",
393
+ help="declare the --issues-file an authoritative export made by NAME (e.g. gh, jira); "
394
+ "the name becomes the idempotency-key prefix")
395
+ ap.add_argument("--apply", action="store_true", help="actually create the kanban tasks")
396
+ ap.add_argument("--target", choices=["kanban", "backlog"], default="kanban",
397
+ help="where a card goes: 'kanban' hands it to the Hermes dispatcher, 'backlog' writes "
398
+ "a markdown ledger file under backlog/ (works without any execution backend)")
399
+ ap.add_argument("--selftest", action="store_true", help="prove the kanban write path, then exit")
400
+ ap.add_argument("--report", default=REPORT_REL)
401
+ ap.add_argument("--print-brief", type=int, help="print the rendered brief for this issue number")
402
+ ap.add_argument("--json", action="store_true")
403
+ args = ap.parse_args(argv)
404
+
405
+ try:
406
+ profile = worker_brief.get_profile(args.root, args.profile)
407
+ except (KeyError, OSError, json.JSONDecodeError) as exc:
408
+ print(f"profile error: {exc}", file=sys.stderr)
409
+ return 2
410
+
411
+ if args.selftest:
412
+ rc = selftest(profile)
413
+ print(PASS_MARKER if rc == 0 else STOP_MARKER)
414
+ return rc
415
+
416
+ # A file is a fixture until its exporter is declared. The declaration is what changes both the answer
417
+ # to "--apply may I?" and the key space, so a dry run cannot silently become a queue by habit.
418
+ exporter = (args.exported_by or "").strip().lower()
419
+ source = "github" if not args.issues_file else ("file" if exporter else "fixture")
420
+ if args.exported_by and not args.issues_file:
421
+ print("refusing --exported-by without --issues-file: a live `gh` read is already authoritative, "
422
+ "and the flag would give the same issues a second key space", file=sys.stderr)
423
+ print(STOP_MARKER)
424
+ return 1
425
+ export_error = exporter_error(args.exported_by) if args.exported_by else None
426
+ if export_error:
427
+ print(f"refusing --exported-by {args.exported_by!r}: {export_error}", file=sys.stderr)
428
+ print(STOP_MARKER)
429
+ return 1
430
+ try:
431
+ issues = (fixture_issues(args.issues_file) if args.issues_file
432
+ else gh_issues(profile["repo"], args.limit))
433
+ except (OSError, RuntimeError, json.JSONDecodeError) as exc:
434
+ print(f"source error ({source}): {exc}", file=sys.stderr)
435
+ print(STOP_MARKER)
436
+ return 1
437
+
438
+ # Authorization is asked first: a refusal that is about the mode must not be buried under a message
439
+ # about the data source.
440
+ refusal = autonomy_refusal(args.root, args.target) if args.apply else None
441
+ if refusal:
442
+ print(f"refusing --apply: {refusal}", file=sys.stderr)
443
+ print(STOP_MARKER)
444
+ return 1
445
+
446
+ if args.apply and source == "fixture":
447
+ print("refusing --apply with --issues-file: an undeclared file is a fixture, and fixture numbers "
448
+ "are not real issues. Mark it authoritative with --exported-by <name> (e.g. "
449
+ "--exported-by gh for a `gh issue list --json` export) if it is the real source.",
450
+ file=sys.stderr)
451
+ print(STOP_MARKER)
452
+ return 1
453
+ if args.apply and source == "file" and args.target == "kanban" and exporter != "gh":
454
+ print(f"refusing --apply --target kanban for exporter {exporter!r}: starting unattended workers is "
455
+ f"a claim about real issue numbers, so the queue accepts only `--exported-by gh`; use "
456
+ f"--target backlog for other trackers", file=sys.stderr)
457
+ print(STOP_MARKER)
458
+ return 1
459
+ target_error = apply_target_error(profile) if (args.apply and args.target == "kanban") else None
460
+ if target_error:
461
+ print(f"refusing --apply: {target_error}", file=sys.stderr)
462
+ print(STOP_MARKER)
463
+ return 1
464
+
465
+ if args.target == "backlog":
466
+ existing = backlog_existing(args.root, args.profile) if args.apply else {}
467
+ else:
468
+ # Any source whose keys live in the `gh` space must consult the board before minting: a file
469
+ # export is authoritative for the same issue numbers a live read would return, so it needs the
470
+ # same "already queued" answer. Without this the bridge reported QUEUED for work the board had
471
+ # already accepted (found by a live `mode: afk` run, where the kanban target really creates cards).
472
+ dedupe = args.apply and args.target == "kanban" and source in ("github", "file") and (
473
+ source == "github" or exporter == "gh")
474
+ existing = queued_numbers(existing_tasks(), args.profile) if dedupe else {}
475
+ report = {
476
+ "marker": PASS_MARKER,
477
+ "profile": args.profile,
478
+ "repo": profile["repo"],
479
+ "source": source,
480
+ "exported_by": exporter or None,
481
+ "key_prefix": exporter or "gh",
482
+ "mode": "apply" if args.apply else "dry-run",
483
+ "generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
484
+ "evidence_bundle": [i["command"] for i in worker_brief.bundle_commands(profile)],
485
+ "queued": [], "already_queued": [], "needs_triage": [], "skipped": [], "errors": [],
486
+ }
487
+
488
+ print(f"issue bridge — {profile['repo']} via {source} "
489
+ f"({'APPLY' if args.apply else 'dry-run; pass --apply to queue'})")
490
+ print(f"evidence bundle required of every worker: "
491
+ f"{', '.join(i['command'] for i in worker_brief.bundle_commands(profile))}")
492
+ print()
493
+
494
+ printed = 0
495
+ created_count = 0
496
+ for issue in sorted(issues, key=lambda i: i["number"]):
497
+ number, title = issue["number"], issue["title"]
498
+ verdict, reason = classify(issue, profile)
499
+ if verdict == "skip":
500
+ report["skipped"].append({"issue": number, "title": title, "reason": reason})
501
+ print(f" #{number:<5} SKIP {title[:60]} — {reason}")
502
+ continue
503
+ if verdict == "needs-triage":
504
+ report["needs_triage"].append({"issue": number, "title": title, "reason": reason})
505
+ print(f" #{number:<5} NEEDS-TRIAGE {title[:60]} — {reason}")
506
+ continue
507
+ if number in existing:
508
+ report["already_queued"].append({"issue": number, "task": existing[number]})
509
+ print(f" #{number:<5} QUEUED {title[:60]} — task {existing[number]} (idempotent)")
510
+ continue
511
+
512
+ brief = worker_brief.render_brief(args.profile, profile, issue)
513
+ key = issue_key(profile, number, exporter or "gh")
514
+ assignee = assignee_for(issue, profile)
515
+ estimate = (load_estimates(args.root).get(assignee) or {})
516
+ cap = estimate.get("max_runtime")
517
+ entry = {"issue": number, "title": title, "idempotency_key": key,
518
+ "assignee": assignee, "max_runtime": cap,
519
+ "estimate_basis": estimate.get("basis"),
520
+ "brief_chars": len(brief)}
521
+ show_brief = args.print_brief == number or (args.print_brief is None and not args.apply
522
+ and not args.json and printed == 0)
523
+ if show_brief:
524
+ printed += 1
525
+ print()
526
+ print(brief)
527
+ print()
528
+ if args.apply and args.target == "kanban":
529
+ cap_cards = autonomy_config(args.root)["max_cards"]
530
+ if isinstance(cap_cards, int) and created_count >= cap_cards:
531
+ print(f" #{number:<5} DEFERRED max_cards={cap_cards} reached: remaining issues wait for "
532
+ f"the next poll")
533
+ report["skipped"].append({"issue": number, "reason": f"max_cards={cap_cards} reached"})
534
+ continue
535
+ if not args.apply:
536
+ entry["planned_task_title"] = task_title(args.profile, issue)
537
+ report["queued"].append(entry)
538
+ print(f" #{number:<5} WOULD-QUEUE {title[:60]} — key {key}")
539
+ continue
540
+ tmp = os.path.join(ROOT, "artifacts", "_bridge_body.md")
541
+ os.makedirs(os.path.dirname(tmp), exist_ok=True)
542
+ with open(tmp, "w", encoding="utf-8") as fh:
543
+ fh.write(brief)
544
+ try:
545
+ created = (backlog_write(args.root, task_title(args.profile, issue), brief, profile, key,
546
+ assignee=assignee, max_runtime=cap)
547
+ if args.target == "backlog" else
548
+ kanban_create(task_title(args.profile, issue), tmp, profile, key,
549
+ assignee=assignee, max_runtime=cap))
550
+ except RuntimeError as exc:
551
+ report["errors"].append({"issue": number, "error": str(exc)})
552
+ print(f" #{number:<5} ERROR {exc}")
553
+ continue
554
+ finally:
555
+ os.remove(tmp)
556
+ if created.get("id") in set(existing.values()) or created.get("status") == "existing":
557
+ # The board answered idempotently: nothing new was minted, so this card must not consume the
558
+ # max_cards budget of this poll either.
559
+ entry["task"] = created.get("id")
560
+ report["already_queued"].append({"issue": number, "task": created.get("id")})
561
+ print(f" #{number:<5} QUEUED {title[:60]} — task {created.get('id')} (idempotent)")
562
+ continue
563
+ created_count += 1
564
+ entry["task"] = created.get("id")
565
+ entry["task_status"] = created.get("status")
566
+ report["queued"].append(entry)
567
+ print(f" #{number:<5} QUEUED {title[:60]} — task {entry['task']} "
568
+ f"({entry['task_status']}, assignee={entry.get('assignee')}, cap={cap or 'none'})")
569
+
570
+ ok = not report["errors"]
571
+ report["marker"] = PASS_MARKER if ok else STOP_MARKER
572
+ report["counts"] = {"queued": len(report["queued"]), "already_queued": len(report["already_queued"]),
573
+ "needs_triage": len(report["needs_triage"]), "skipped": len(report["skipped"]),
574
+ "errors": len(report["errors"])}
575
+ report_path = args.report if os.path.isabs(args.report) else os.path.join(args.root, args.report)
576
+ os.makedirs(os.path.dirname(report_path), exist_ok=True)
577
+ with open(report_path, "w", encoding="utf-8") as fh:
578
+ json.dump(report, fh, ensure_ascii=False, indent=2, sort_keys=True)
579
+ fh.write("\n")
580
+
581
+ counts = report["counts"]
582
+ print()
583
+ print(f"issues: {len(issues)} — queued {counts['queued']}, already queued {counts['already_queued']}, "
584
+ f"needs triage {counts['needs_triage']}, skipped {counts['skipped']}, errors {counts['errors']}")
585
+ print(f"report: {os.path.relpath(report_path, args.root)}")
586
+ if args.json:
587
+ print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
588
+ print(PASS_MARKER if ok else STOP_MARKER)
589
+ return 0 if ok else 1
590
+
591
+
592
+ if __name__ == "__main__":
593
+ sys.exit(main())