raidlock 0.1.0__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 (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/prsync.py ADDED
@@ -0,0 +1,318 @@
1
+ """Pull request awareness: link cards to PRs, poll state and checks through gh, close merged cards through the gates."""
2
+
3
+ import json
4
+ import re
5
+ import time
6
+
7
+ from . import pr as P
8
+ from .core import clock, model
9
+ from .core.errors import Precondition
10
+ from .runners import policy
11
+
12
+ FIELDS = "number,url,state,isDraft,mergedAt,headRefOid,headRefName,baseRefName,statusCheckRollup,reviewDecision"
13
+ RATE = re.compile(r"rate limit|abuse detection|HTTP 429", re.IGNORECASE)
14
+ PASS = {"SUCCESS"}
15
+ FAIL = {"FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"}
16
+ STATES = ("OPEN", "CLOSED", "MERGED")
17
+ SHA = re.compile(r"^[0-9a-f]{40}([0-9a-f]{24})?$")
18
+ MAX_BACKOFF = 900
19
+
20
+
21
+ class RateLimited(Precondition):
22
+ kind = "rate_limited"
23
+
24
+
25
+ def parse_ref(ref):
26
+ text = str(ref or "").strip().lstrip("#")
27
+ if text.isdigit() and 0 < int(text) < 10**9:
28
+ return {"number": int(text), "url": ""}
29
+ match = P.PR_URL.fullmatch(text)
30
+ if match and int(match.group(1)) > 0:
31
+ return {"number": int(match.group(1)), "url": text}
32
+ raise Precondition("a pull request is a number or a .../pull/<n> URL", ref=text[:200])
33
+
34
+
35
+ def summarize(rollup):
36
+ """Collapse gh's statusCheckRollup into counts and one state: success, failure, pending or none."""
37
+ passed = failed = pending = 0
38
+ failing = []
39
+ for item in rollup if isinstance(rollup, list) else []:
40
+ if not isinstance(item, dict):
41
+ continue
42
+ name = str(item.get("name") or item.get("context") or "check")[:100]
43
+ if item.get("__typename") == "StatusContext" or ("state" in item and "conclusion" not in item):
44
+ state = str(item.get("state", "")).upper()
45
+ if state == "SUCCESS":
46
+ passed += 1
47
+ elif state in ("FAILURE", "ERROR"):
48
+ failed += 1
49
+ failing.append(name)
50
+ else:
51
+ pending += 1
52
+ continue
53
+ if str(item.get("status", "")).upper() != "COMPLETED":
54
+ pending += 1
55
+ continue
56
+ conclusion = str(item.get("conclusion", "")).upper()
57
+ if conclusion in PASS:
58
+ passed += 1
59
+ elif conclusion in FAIL:
60
+ failed += 1
61
+ failing.append(name)
62
+ else:
63
+ pending += 1
64
+ total = passed + failed + pending
65
+ state = "failure" if failed else "pending" if pending else "success" if total else "none"
66
+ return {"state": state, "passed": passed, "failed": failed, "pending": pending, "total": total, "failing": failing}
67
+
68
+
69
+ def _repo_path(board, card):
70
+ try:
71
+ return policy.target(board.workspace, board.dir, card["locks"])["path"]
72
+ except Precondition:
73
+ return None
74
+
75
+
76
+ @P.protected
77
+ def fetch(board, card):
78
+ """Current PR state from gh, validated. Raises RateLimited when GitHub throttles."""
79
+ pr = card["pr"]
80
+ proc = P.gh(
81
+ ["pr", "view", pr.get("url") or str(pr["number"]), "--json", FIELDS], cwd=_repo_path(board, card), env=board.env
82
+ )
83
+ if proc is None:
84
+ raise Precondition("gh is not installed", hint="install the GitHub CLI and run gh auth login")
85
+ if proc.returncode != 0:
86
+ err = P.sanitize(proc.stderr or proc.stdout, board.env).strip()
87
+ if RATE.search(err):
88
+ raise RateLimited("GitHub rate limit reached; try again later", card=card["id"])
89
+ raise Precondition(f"gh pr view failed for {card['id']}: {err[:300]}", card=card["id"])
90
+ try:
91
+ data = json.loads(proc.stdout)
92
+ except ValueError:
93
+ raise Precondition(f"gh returned invalid JSON for {card['id']}", card=card["id"]) from None
94
+ if not isinstance(data, dict) or data.get("number") != pr["number"]:
95
+ raise Precondition(f"gh returned a different pull request for {card['id']}", card=card["id"])
96
+ state = str(data.get("state", "")).upper()
97
+ head = str(data.get("headRefOid") or "")
98
+ if state not in STATES or (head and not SHA.match(head)):
99
+ raise Precondition(f"gh returned an unexpected pull request state for {card['id']}", card=card["id"])
100
+ url = str(data.get("url") or pr.get("url") or "")
101
+ if pr.get("url") and url != pr["url"]:
102
+ raise Precondition(f"gh returned a different pull request for {card['id']}", card=card["id"])
103
+ return {
104
+ "state": state,
105
+ "draft": bool(data.get("isDraft")),
106
+ "merged_at": str(data.get("mergedAt") or ""),
107
+ "head": head,
108
+ "head_branch": str(data.get("headRefName") or "")[:200],
109
+ "base": str(data.get("baseRefName") or "")[:200],
110
+ "review_decision": str(data.get("reviewDecision") or ""),
111
+ "checks": summarize(data.get("statusCheckRollup")),
112
+ "url": url if P.PR_URL.fullmatch(url) else pr.get("url", ""),
113
+ }
114
+
115
+
116
+ def link(board, caller, card_id, ref):
117
+ """Lead: link a card to an existing pull request."""
118
+ found = parse_ref(ref)
119
+ with board.lead_write(caller, "link a pull request"):
120
+ card = board.card(card_id)
121
+ previous = card.get("pr") or {}
122
+ card["pr"] = {
123
+ "number": found["number"],
124
+ "url": found["url"] or (previous.get("url") if previous.get("number") == found["number"] else ""),
125
+ "linked": clock.iso(),
126
+ "state": "",
127
+ "checks": summarize([]),
128
+ }
129
+ board._note(card, caller.name, f"linked pull request #{found['number']}")
130
+ board._save_card(card)
131
+ board._record(
132
+ "status",
133
+ caller.name,
134
+ f"{card_id} linked to pull request #{found['number']}",
135
+ task=card_id,
136
+ data={"event": "pr_linked", "pr": card["pr"]},
137
+ )
138
+ return {"card": card_id, "pr": card["pr"]}
139
+
140
+
141
+ def _due(card, min_interval, force):
142
+ pr = card["pr"]
143
+ if force:
144
+ return True
145
+ if card["lane"] == "done" and pr.get("state") in ("MERGED", "CLOSED"):
146
+ return False
147
+ if pr.get("state") == "CLOSED":
148
+ return False
149
+ return not pr.get("synced") or clock.age_seconds(pr["synced"]) >= min_interval
150
+
151
+
152
+ def hold_reason(board, card, info):
153
+ """Why a merged PR does not close its card yet, or "" when the gates may run."""
154
+ if card["lane"] not in ("review", "blocked"):
155
+ return f"card is in {card['lane']}"
156
+ last = model.last_verification(card) or {}
157
+ if not last.get("sha"):
158
+ return "no verify receipt"
159
+ if info["head"] != last["sha"]:
160
+ return f"merged head {info['head'][:12]} is not the verified commit {last['sha'][:12]}"
161
+ if info["checks"]["state"] == "failure":
162
+ return "checks failed: " + ", ".join(info["checks"]["failing"][:5])
163
+ if info["checks"]["state"] != "success":
164
+ return "checks not successful: " + info["checks"]["state"]
165
+ return ""
166
+
167
+
168
+ def _update(board, caller, card_id, info):
169
+ with board.lead_write(caller, "sync a pull request"):
170
+ card = board.card(card_id)
171
+ pr = card.get("pr") or {}
172
+ before = (pr.get("state"), (pr.get("checks") or {}).get("state"))
173
+ pr.update(info, synced=clock.iso())
174
+ card["pr"] = pr
175
+ after = (info["state"], info["checks"]["state"])
176
+ if before != after:
177
+ board._note(card, caller.name, f"pull request #{pr['number']}: {after[0].lower()}, checks {after[1]}")
178
+ board._record(
179
+ "status",
180
+ caller.name,
181
+ f"pull request #{pr['number']} {after[0].lower()}, checks {after[1]}",
182
+ task=card_id,
183
+ data={"event": "pr_state", "state": after[0], "checks": info["checks"]},
184
+ )
185
+ board._save_card(card)
186
+ return card
187
+
188
+
189
+ def _hold(board, caller, card_id, reason):
190
+ with board.lead_write(caller, "hold a merged pull request"):
191
+ card = board.card(card_id)
192
+ if card["pr"].get("held") != reason:
193
+ card["pr"]["held"] = reason
194
+ board._note(card, caller.name, f"merged but not done: {reason}")
195
+ board._record(
196
+ "status",
197
+ caller.name,
198
+ f"{card_id} merged but not done: {reason}",
199
+ task=card_id,
200
+ data={"event": "pr_merge_held", "reason": reason},
201
+ )
202
+ board._save_card(card)
203
+
204
+
205
+ def _advance(board, caller, card, info):
206
+ reason = hold_reason(board, card, info)
207
+ if not reason:
208
+ try:
209
+ board.move(card["id"], "done", caller, note=f"merged as pull request #{card['pr']['number']}")
210
+ from .runners.runner import prune_runs
211
+
212
+ prune_runs(board, card["id"])
213
+ return "done", ""
214
+ except Precondition as exc:
215
+ reason = exc.message
216
+ _hold(board, caller, card["id"], reason)
217
+ return "held", reason
218
+
219
+
220
+ def sync(board, caller, ids=None, min_interval=60, force=False):
221
+ """Lead: poll each linked PR once (respecting min_interval), record state and checks, and move merged cards to
222
+ done only through the done gate. Stops at the first rate limit."""
223
+ board._auth(caller)
224
+ board._require_lead(caller, "sync pull requests")
225
+ cards = board.cards()
226
+ if ids:
227
+ known = {c["id"]: c for c in cards}
228
+ for cid in ids:
229
+ if cid not in known:
230
+ raise Precondition(f"no card {cid}", card=cid)
231
+ if not known[cid].get("pr"):
232
+ raise Precondition(f"{cid} has no pull request", card=cid, hint=f"raidlock pr-link {cid} <number>")
233
+ cards = [known[cid] for cid in ids]
234
+ else:
235
+ cards = [c for c in cards if c.get("pr")]
236
+ out = {"synced": [], "skipped": [], "done": [], "held": [], "errors": [], "rate_limited": False}
237
+ for card in cards:
238
+ if not _due(card, min_interval, force):
239
+ out["skipped"].append(card["id"])
240
+ continue
241
+ try:
242
+ info = fetch(board, card)
243
+ except RateLimited:
244
+ out["rate_limited"] = True
245
+ break
246
+ except Precondition as exc:
247
+ out["errors"].append({"card": card["id"], "error": exc.message})
248
+ continue
249
+ with board.lead_write(caller, "sync a pull request"):
250
+ current = board.card(card["id"])
251
+ identity = ("number", "url", "linked", "opened", "repo")
252
+ if any((current.get("pr") or {}).get(k) != card["pr"].get(k) for k in identity):
253
+ out["errors"].append({"card": card["id"], "error": "pull request changed during sync"})
254
+ continue
255
+ card = _update(board, caller, card["id"], info)
256
+ out["synced"].append({"card": card["id"], "state": info["state"], "checks": info["checks"]["state"]})
257
+ if info["state"] == "MERGED" and card["lane"] != "done":
258
+ verdict, reason = _advance(board, caller, card, info)
259
+ if verdict == "done":
260
+ out["done"].append(card["id"])
261
+ else:
262
+ out["held"].append({"card": card["id"], "reason": reason})
263
+ return out
264
+
265
+
266
+ def watch(board, caller, interval=60, max_polls=None, sleep=time.sleep, **kw):
267
+ """Repeat sync; double the wait after a rate limit, up to MAX_BACKOFF, and reset after a clean poll."""
268
+ polls, wait, last = 0, interval, None
269
+ while max_polls is None or polls < max_polls:
270
+ last = sync(board, caller, **kw)
271
+ polls += 1
272
+ wait = min(MAX_BACKOFF, wait * 2) if last["rate_limited"] else interval
273
+ if max_polls is not None and polls >= max_polls:
274
+ break
275
+ sleep(wait)
276
+ return {**(last or {}), "polls": polls, "next_wait": wait}
277
+
278
+
279
+ def register_cli(add_command):
280
+ def link_setup(p):
281
+ p.add_argument("id", help="card id")
282
+ p.add_argument("ref", help="pull request number or URL")
283
+
284
+ def link_handler(ctx, a):
285
+ r = link(ctx.board, ctx.caller(), a.id, a.ref)
286
+ return r, f"Linked {a.id} to pull request #{r['pr']['number']}."
287
+
288
+ def sync_setup(p):
289
+ p.add_argument("ids", nargs="*", help="cards to sync (default: every card with a pull request)")
290
+ p.add_argument("--min-interval", type=float, default=60, help="seconds between polls of one PR")
291
+ p.add_argument("--force", action="store_true", help="poll even when recently synced or closed")
292
+ p.add_argument("--watch", type=float, default=None, metavar="SECONDS", help="keep polling at this interval")
293
+ p.add_argument("--max-polls", type=int, default=None)
294
+
295
+ def sync_handler(ctx, a):
296
+ kw = {"ids": a.ids, "min_interval": a.min_interval, "force": a.force}
297
+ if a.watch:
298
+ r = watch(ctx.board, ctx.caller(), interval=a.watch, max_polls=a.max_polls, **kw)
299
+ else:
300
+ r = sync(ctx.board, ctx.caller(), **kw)
301
+ lines = [f"{s['card']}: {s['state'].lower()}, checks {s['checks']}" for s in r["synced"]]
302
+ lines += [f"{cid}: merged, moved to done" for cid in r["done"]]
303
+ lines += [f"{h['card']}: merged, held: {h['reason']}" for h in r["held"]]
304
+ lines += [f"{e['card']}: {e['error']}" for e in r["errors"]]
305
+ if r["rate_limited"]:
306
+ lines.append("Stopped: GitHub rate limit reached; run again later.")
307
+ if not lines:
308
+ lines.append("Nothing to sync." + (f" Skipped: {', '.join(r['skipped'])}." if r["skipped"] else ""))
309
+ return {**r, "_exit": 2 if r["rate_limited"] or r["errors"] else 0}, "\n".join(lines)
310
+
311
+ add_command("pr-link", "lead: link a card to an existing pull request", link_setup, link_handler, True)
312
+ add_command(
313
+ "pr-sync",
314
+ "lead: poll linked pull requests; merged cards close only through the gates",
315
+ sync_setup,
316
+ sync_handler,
317
+ True,
318
+ )
@@ -0,0 +1,34 @@
1
+ # Built-in recipes
2
+
3
+ The loader uses stdlib `importlib.resources` to read the packaged Markdown.
4
+ The existing wheel configuration includes this directory as package data.
5
+
6
+ The package exposes `read_plan(source, team=None, variables=None)`,
7
+ `parse_variables(assignments)` and `list_recipes()`. The core CLI owns
8
+ `plan import`, dispatches `recipe:` sources through `read_plan`, and retains
9
+ ordinary file imports and their mutating authorization. This package's
10
+ `register_cli(add_command)` registers only the read-only `recipes` listing.
11
+
12
+ ```sh
13
+ raidlock recipes
14
+ raidlock --lead-secret /printed/path plan import recipe:upgrade-deps --var repo=acme-shop
15
+ raidlock --lead-secret /printed/path plan import recipe:security-audit --var repo=acme-shop
16
+ raidlock --lead-secret /printed/path plan import recipe:docs-refresh --var repo=pocket-notes
17
+ raidlock --lead-secret /printed/path plan import recipe:multi-repo-refactor --var repo=acme-shop --var other_repo=pocket-notes
18
+ ```
19
+
20
+ Recipe imports require repository variables; ordinary file imports reject
21
+ `--var`. Recipe IDs are stable, so importing the same recipe twice into a
22
+ board requires resolving its existing cards first.
23
+
24
+ Each checklist uses the existing `- [ ] Title | locks: repo/path, repo/path`
25
+ format. The loader reads each preceding `Quest:` JSON paragraph for its stable
26
+ ID, dependencies and scope. Ordinary Markdown import ignores those paragraphs;
27
+ use the recipe loader to retain dependencies and assignment instructions.
28
+
29
+ Variables are required repository names consisting of letters, digits, hyphens
30
+ or underscores. Paths, whitespace and Markdown syntax are rejected. Adjust
31
+ recipe lock paths to the target repository layout before assigning work.
32
+ Security review quests require a different provider from their build quest;
33
+ the lead must enforce the assignments described in scope. They produce separate
34
+ reports after builds finish and do not substitute for board approval gates.
@@ -0,0 +1,105 @@
1
+ """Built-in plans and an optional CLI registration hook."""
2
+
3
+ import json
4
+ import re
5
+ from importlib.resources import files
6
+
7
+ from raidlock.core import Precondition
8
+ from raidlock.core.paths import parse_list
9
+
10
+ _NAMES = ("docs-refresh", "multi-repo-refactor", "security-audit", "upgrade-deps")
11
+ _VARIABLE = re.compile(r"\{([a-z][a-z0-9_]*)\}")
12
+
13
+
14
+ def list_recipes():
15
+ return list(_NAMES)
16
+
17
+
18
+ def load_recipe(source, variables=None):
19
+ name = source.removeprefix("recipe:")
20
+ if name not in _NAMES:
21
+ raise Precondition(f"unknown recipe: {name}", hint="raidlock recipes")
22
+ values = dict(variables or {})
23
+ text = files(__package__).joinpath(name + ".md").read_text(encoding="utf-8")
24
+ required = set(_VARIABLE.findall(text))
25
+ missing = required - values.keys()
26
+ if missing:
27
+ raise Precondition("missing recipe variables: " + ", ".join(sorted(missing)))
28
+ for key in required:
29
+ value = values[key]
30
+ if not isinstance(value, str) or not re.fullmatch(r"[a-zA-Z0-9_-]+", value):
31
+ raise Precondition(f"invalid recipe variable {key}: use a repository name")
32
+ return _VARIABLE.sub(lambda match: values[match[1]], text)
33
+
34
+
35
+ def read_plan(source, team=None, variables=None):
36
+ from raidlock.cli import _md_item, _read_plan
37
+
38
+ if not source.startswith("recipe:"):
39
+ if variables:
40
+ raise Precondition("--var is supported only for recipes")
41
+ return _read_plan(source, team)
42
+ items = []
43
+ metadata = None
44
+ seen = set()
45
+ for line in load_recipe(source, variables).splitlines():
46
+ if line.startswith("Quest: "):
47
+ metadata = json.loads(line[7:])
48
+ elif line.startswith("- [ ] "):
49
+ if metadata is None:
50
+ raise Precondition("recipe quest is missing metadata")
51
+ item = {**_md_item(line), **metadata}
52
+ if item["id"] in seen or not set(item.get("depends", [])) <= seen:
53
+ raise Precondition("recipe dependencies must reference earlier unique quests")
54
+ seen.add(item["id"])
55
+ item["locks"] = parse_list(item["locks"])
56
+ if team:
57
+ item["team"] = team
58
+ items.append(item)
59
+ metadata = None
60
+ return items
61
+
62
+
63
+ def parse_variables(assignments):
64
+ variables = {}
65
+ for assignment in assignments or []:
66
+ key, separator, value = assignment.partition("=")
67
+ if not separator or not key or key in variables:
68
+ raise Precondition(f"invalid or duplicate --var: {assignment}")
69
+ variables[key] = value
70
+ return variables
71
+
72
+
73
+ def setup_plan(parser):
74
+ parser.add_argument("action", choices=["import", "recipes"])
75
+ parser.add_argument("file", nargs="?")
76
+ parser.add_argument("--team", default=None)
77
+ parser.add_argument("--var", action="append", default=[])
78
+
79
+
80
+ def handle_plan(ctx, args):
81
+ if args.action == "recipes":
82
+ if args.file or args.var or args.team:
83
+ raise Precondition("plan recipes takes no import arguments")
84
+ names = list_recipes()
85
+ return {"recipes": names}, "\n".join(names)
86
+ if not args.file:
87
+ raise Precondition("plan import requires a file or recipe:<name>")
88
+ variables = parse_variables(args.var)
89
+ items = read_plan(args.file, args.team, variables)
90
+ cards = ctx.board.import_plan(ctx.caller(), items)
91
+ ids = [card["id"] for card in cards]
92
+ return {"cards": ids}, f"Imported {len(ids)} card(s): {', '.join(ids)}.\nNext: raidlock show"
93
+
94
+
95
+ def setup_recipes(parser):
96
+ pass
97
+
98
+
99
+ def handle_recipes(ctx, args):
100
+ names = list_recipes()
101
+ return {"recipes": names}, "\n".join(names)
102
+
103
+
104
+ def register_cli(add_command):
105
+ add_command("recipes", "list built-in plan recipes", setup_recipes, handle_recipes)
@@ -0,0 +1,15 @@
1
+ # docs-refresh
2
+
3
+ Use when documentation has drifted from current behavior.
4
+
5
+ Quest: {"id": "refresh-guide", "depends": [], "scope": "Check documented commands against current behavior and update examples."}
6
+
7
+ - [ ] Refresh user guides for {repo} | locks: {repo}/docs
8
+
9
+ Quest: {"id": "refresh-readme", "depends": ["refresh-guide"], "scope": "Update setup instructions and links to the refreshed guides."}
10
+
11
+ - [ ] Refresh the README for {repo} | locks: {repo}/README.md
12
+
13
+ Quest: {"id": "verify-docs", "depends": ["refresh-readme"], "scope": "Run examples, check links and correct inconsistencies."}
14
+
15
+ - [ ] Verify documentation examples for {repo} | locks: {repo}/docs
@@ -0,0 +1,15 @@
1
+ # multi-repo-refactor
2
+
3
+ Use for a shared contract change across two repositories. Keep the producer backward compatible until the consumer migrates.
4
+
5
+ Quest: {"id": "refactor-producer", "depends": [], "scope": "Introduce the new contract while preserving the old interface and test both."}
6
+
7
+ - [ ] Refactor the producer in {repo} | locks: {repo}/src, {repo}/tests
8
+
9
+ Quest: {"id": "refactor-consumer", "depends": ["refactor-producer"], "scope": "Adopt the new contract and test compatibility with the producer."}
10
+
11
+ - [ ] Migrate the consumer in {other_repo} | locks: {other_repo}/src, {other_repo}/tests
12
+
13
+ Quest: {"id": "verify-contract", "depends": ["refactor-consumer"], "scope": "Run integration tests across both repositories and document rollout and rollback steps."}
14
+
15
+ - [ ] Verify the contract across {repo} and {other_repo} | locks: {repo}/tests, {other_repo}/tests
@@ -0,0 +1,27 @@
1
+ # security-audit
2
+
3
+ Use for a security audit covering dependencies, authentication and input handling. Each area has one build quest and one independent review quest. The lead must assign builds to openai and reviews to anthropic; provider assignment is an instruction, not an importer-enforced field. Review quests follow completed builds and do not replace board approval gates.
4
+
5
+ Quest: {"id": "build-dependencies", "depends": [], "scope": "Assign to provider openai. Audit dependencies and repair vulnerable versions with regression evidence."}
6
+
7
+ - [ ] Build security fixes for dependencies in {repo} | locks: {repo}/pyproject.toml, {repo}/requirements.txt
8
+
9
+ Quest: {"id": "review-dependencies", "depends": ["build-dependencies"], "scope": "Assign to provider anthropic, different from the build provider openai. Independently reproduce findings, inspect the patch and tests, and write a review report. Do not modify build files."}
10
+
11
+ - [ ] Independently review dependencies fixes in {repo} | locks: {repo}/audit-reviews/dependencies.md
12
+
13
+ Quest: {"id": "build-auth", "depends": [], "scope": "Assign to provider openai. Audit authentication and authorization boundaries and repair demonstrated flaws."}
14
+
15
+ - [ ] Build security fixes for auth in {repo} | locks: {repo}/src/auth, {repo}/tests/auth
16
+
17
+ Quest: {"id": "review-auth", "depends": ["build-auth"], "scope": "Assign to provider anthropic, different from the build provider openai. Independently reproduce findings, inspect the patch and tests, and write a review report. Do not modify build files."}
18
+
19
+ - [ ] Independently review auth fixes in {repo} | locks: {repo}/audit-reviews/auth.md
20
+
21
+ Quest: {"id": "build-inputs", "depends": [], "scope": "Assign to provider openai. Audit input validation and injection risks and add regression tests for fixes."}
22
+
23
+ - [ ] Build security fixes for inputs in {repo} | locks: {repo}/src/inputs, {repo}/tests/inputs
24
+
25
+ Quest: {"id": "review-inputs", "depends": ["build-inputs"], "scope": "Assign to provider anthropic, different from the build provider openai. Independently reproduce findings, inspect the patch and tests, and write a review report. Do not modify build files."}
26
+
27
+ - [ ] Independently review inputs fixes in {repo} | locks: {repo}/audit-reviews/inputs.md
@@ -0,0 +1,15 @@
1
+ # upgrade-deps
2
+
3
+ Use for dependency upgrades that need compatibility checks before release.
4
+
5
+ Quest: {"id": "upgrade-runtime", "depends": [], "scope": "Update runtime constraints and lockfiles and record compatibility changes."}
6
+
7
+ - [ ] Upgrade runtime dependencies in {repo} | locks: {repo}/pyproject.toml, {repo}/requirements.txt
8
+
9
+ Quest: {"id": "upgrade-tooling", "depends": ["upgrade-runtime"], "scope": "Update development tools and regenerate affected lockfiles."}
10
+
11
+ - [ ] Upgrade development tools in {repo} | locks: {repo}/pyproject.toml, {repo}/requirements-dev.txt
12
+
13
+ Quest: {"id": "verify-upgrade", "depends": ["upgrade-tooling"], "scope": "Run the full suite and add regression tests for changed dependency behavior."}
14
+
15
+ - [ ] Verify upgraded dependencies in {repo} | locks: {repo}/tests