gabriel / muse public
proposals.py python
1,151 lines 47.6 KB
Raw
sha256:371209e571fc5fc0010114aaa8f272b15179a2245abd7f62320fa700fbe2a60b feat: add url and base64 id to hub issue/proposal/repo crea… Sonnet 4.6 patch 2 days ago
1 import argparse
2 from ._core import *
3
4 def run_proposal_list(args: argparse.Namespace) -> None:
5 """List proposals on MuseHub.
6
7 Filters by state (default: ``open``) and respects ``--limit`` (default: 20).
8 Use ``--state all`` to list every proposal regardless of state.
9 Use ``--verbose`` / ``-v`` to show author and creation date per proposal.
10
11 JSON output goes to stdout; human-readable text goes to stderr.
12 With ``--json`` the response is a JSON **object** with envelope fields plus
13 ``proposals`` (array), ``total`` (int), and ``next_cursor`` (str|null).
14 ``--verbose`` has no effect when ``--json`` is set — callers receive the
15 full API response and can select any field with ``jq``.
16
17 Agent quickstart
18 ----------------
19 ::
20
21 muse hub proposal list --state open --json
22 muse hub proposal list --state all -n 100 --json | jq '.proposals[].proposalId'
23 muse hub proposal list --verbose
24
25 Exit codes
26 ----------
27 0 Success (including empty list).
28 1 Not authenticated or no hub configured.
29 2 Not inside a Muse repository.
30 3 API / network error.
31 """
32 state: str = args.state
33 limit: int = clamp_int(args.limit, 1, 10000, "limit")
34 json_output: bool = args.json_output
35 verbose: bool = getattr(args, "verbose", False)
36
37 elapsed = start_timer()
38 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
39 repo_id = _resolve_repo_id(hub_url, identity)
40
41 params = f"?limit={limit}"
42 if state != "all":
43 params += f"&state={state}"
44 data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/proposals{params}")
45 proposals_val = data.get("proposals", [])
46 proposals: list[_ProposalEntry] = (
47 [r for r in proposals_val if isinstance(r, dict)]
48 if isinstance(proposals_val, list) else []
49 )
50 prop_total: int = int(data.get("total", len(proposals)))
51 prop_next_cursor: str | None = data.get("nextCursor") or data.get("next_cursor") # type: ignore[assignment]
52
53 if json_output:
54 print(json.dumps({**make_envelope(elapsed), **{
55 "proposals": proposals,
56 "total": prop_total,
57 "next_cursor": prop_next_cursor,
58 }}))
59 return
60
61 if not proposals:
62 print(f" No proposals found (state={state}).", file=sys.stderr)
63 return
64
65 print(
66 f"\n Proposals — {sanitize_display(hub_url)} ({len(proposals)} shown)",
67 file=sys.stderr,
68 )
69 print(f" {'─' * 60}", file=sys.stderr)
70 for proposal in proposals:
71 print(_format_proposal(proposal, verbose=verbose), file=sys.stderr)
72 print("", file=sys.stderr)
73
74 def run_proposal_read(args: argparse.Namespace) -> None:
75 """Read a single proposal by ID.
76
77 Accepts the full ID or an 8-character prefix. Prefix resolution fetches
78 the most recent :data:`_PROPOSAL_PREFIX_RESOLVE_LIMIT` proposals — use the full ID
79 on large repositories to avoid ambiguity.
80
81 Text output (stderr) shows: state icon, title, ID, branches, author,
82 creation date, and the first :data:`_MAX_PROPOSAL_BODY_LINES` lines of the body.
83 When the body is truncated a hint is printed; pass ``--json`` to retrieve
84 the full body.
85
86 JSON output is the raw API response merged with the standard envelope.
87 API field names follow MuseHub's camelCase convention (``proposalId``,
88 ``fromBranch``, etc.).
89
90 Agent quickstart
91 ----------------
92 ::
93
94 muse hub proposal read af54753d --json | jq '.state'
95 muse hub proposal read af54753d --json | jq '{state,title,author}'
96
97 Exit codes
98 ----------
99 0 Proposal found and printed.
100 1 Prefix not found, ambiguous, or not authenticated.
101 2 Not inside a Muse repository.
102 3 API / network error.
103 """
104 proposal_id: str = args.proposal_id
105 json_output: bool = args.json_output
106
107 elapsed = start_timer()
108 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
109 repo_id = _resolve_repo_id(hub_url, identity)
110 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id)
111
112 data = _hub_api(hub_url, identity, "GET", f"/api/repos/{repo_id}/proposals/{proposal_id}")
113
114 if json_output:
115 print(json.dumps({**make_envelope(elapsed), **data}))
116 return
117
118 title = sanitize_display(str(data.get("title", "(no title)")))
119 state = str(data.get("state", "?"))
120 from_b = sanitize_display(str(data.get("fromBranch", "?")))
121 to_b = sanitize_display(str(data.get("toBranch", "?")))
122 full_id = sanitize_display(str(data.get("proposalId", proposal_id)))
123 author = sanitize_display(str(data.get("author", "")))
124 created = sanitize_display(str(data.get("createdAt", ""))[:10])
125 body = str(data.get("body", ""))
126 # state_icon lookup uses the raw state string as a dict key — safe; the
127 # fallback "❓" handles any unexpected value. The display string is
128 # sanitized separately so ANSI-injected state values don't reach the terminal.
129 state_icon = {"open": "🟢", "merged": "🟣", "closed": "⛔"}.get(state, "❓")
130 state_display = sanitize_display(state)
131
132 print(f"\n {state_icon} [{state_display.upper()}] {title}", file=sys.stderr)
133 print(f" ID: {full_id}", file=sys.stderr)
134 print(f" Branch: {from_b} → {to_b}", file=sys.stderr)
135 if author:
136 print(f" By: {author} {created}", file=sys.stderr)
137 if body:
138 body_lines = body.strip().splitlines()
139 print(" Body:", file=sys.stderr)
140 for line in body_lines[:_MAX_PROPOSAL_BODY_LINES]:
141 print(f" {sanitize_display(line)}", file=sys.stderr)
142 if len(body_lines) > _MAX_PROPOSAL_BODY_LINES:
143 remaining = len(body_lines) - _MAX_PROPOSAL_BODY_LINES
144 print(
145 f" ... ({remaining} more line{'s' if remaining != 1 else ''}"
146 " — use --json to see full body)",
147 file=sys.stderr,
148 )
149 print("", file=sys.stderr)
150
151 def run_proposal_create(args: argparse.Namespace) -> None:
152 """Open a new proposal on MuseHub.
153
154 Uses the current branch as the source if ``--from-branch`` is not given.
155 Both Muse-native (``--from-branch`` / ``--to-branch``) and git-familiar
156 aliases (``--head`` / ``--base``) are accepted.
157
158 All local validation (title length, branch detection) runs before any
159 network I/O, so errors are reported immediately without contacting the hub.
160
161 Branch detection falls back to the current branch in ``.muse/HEAD``.
162 When the repository is in detached HEAD state and ``--from-branch`` is
163 not given, a clear error is printed and the command exits with code 1.
164
165 Title is validated client-side: must be non-empty and no longer than
166 :data:`_MAX_PROPOSAL_TITLE_LEN` characters.
167
168 JSON output is the raw API response merged with the standard envelope.
169 Human-readable text goes to stderr.
170
171 Agent quickstart
172 ----------------
173 ::
174
175 muse hub proposal create --title "feat: x" --from-branch feat/x --json
176 # → {"muse_version": "...", ..., "proposalId": "...", "state": "open"}
177
178 # Full idiomatic flow:
179 PROPOSAL=$(muse hub proposal create --title "feat: x" --json)
180 echo "$PROPOSAL" | jq '.proposalId'
181
182 Exit codes
183 ----------
184 0 Proposal created.
185 1 Title empty/too long, branch cannot be determined, or not authenticated.
186 2 Not inside a Muse repository.
187 3 API / network error.
188 """
189 title: str = args.title
190 body: str = _resolve_body(args)
191 to_branch: str = args.to_branch
192 json_output: bool = args.json_output
193
194 # ── Local validation first — fail fast before any network I/O ────────────
195
196 # Client-side title validation — better UX than a raw API 400.
197 if not title.strip():
198 print("❌ Proposal title must not be empty.", file=sys.stderr)
199 raise SystemExit(ExitCode.USER_ERROR)
200 if len(title) > _MAX_PROPOSAL_TITLE_LEN:
201 print(
202 f"❌ Proposal title is too long ({len(title)} chars); "
203 f"maximum is {_MAX_PROPOSAL_TITLE_LEN}.",
204 file=sys.stderr,
205 )
206 raise SystemExit(ExitCode.USER_ERROR)
207
208 # Resolve source branch before making any network calls so that a
209 # detached-HEAD error or missing branch is caught locally and quickly.
210 from_branch: str = args.from_branch or ""
211 if not from_branch:
212 ctx = find_repo_root()
213 if ctx is not None:
214 try:
215 from_branch = read_current_branch(ctx)
216 except ValueError as exc:
217 # Detached HEAD — read_current_branch raises ValueError.
218 print(f"❌ {exc}", file=sys.stderr)
219 print(
220 " Use --from-branch to specify the source branch explicitly.",
221 file=sys.stderr,
222 )
223 raise SystemExit(ExitCode.USER_ERROR) from exc
224 if not from_branch:
225 print(
226 "❌ Could not determine current branch. Use --from-branch.",
227 file=sys.stderr,
228 )
229 raise SystemExit(ExitCode.USER_ERROR)
230
231 # ── Network calls ─────────────────────────────────────────────────────────
232
233 elapsed = start_timer()
234 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
235 repo_id = _resolve_repo_id(hub_url, identity)
236
237 payload: _HubPayload = {
238 "title": title,
239 "body": body,
240 "fromBranch": from_branch,
241 "toBranch": to_branch,
242 }
243 data = _hub_api(
244 hub_url, identity, "POST",
245 f"/api/repos/{repo_id}/proposals",
246 body=payload,
247 )
248
249 if json_output:
250 print(json.dumps({**make_envelope(elapsed), **data}))
251 return
252
253 proposal_id = str(data.get("proposalId", ""))
254 parsed = urllib.parse.urlparse(hub_url)
255 parts = parsed.path.strip("/").split("/")
256 if len(parts) >= 2:
257 owner, slug = parts[0], parts[1]
258 server_root = f"{parsed.scheme}://{parsed.netloc}"
259 proposal_url = (
260 f"{sanitize_display(server_root)}/{sanitize_display(owner)}"
261 f"/{sanitize_display(slug)}/proposals/{sanitize_display(proposal_id)}"
262 )
263 print(f"✅ Proposal created: {proposal_url}", file=sys.stderr)
264 else:
265 print(f"✅ Proposal created: {sanitize_display(proposal_id)}", file=sys.stderr)
266 print(
267 f" {sanitize_display(from_branch)} → {sanitize_display(to_branch)}: "
268 f"{sanitize_display(title)}",
269 file=sys.stderr,
270 )
271
272 def run_proposal_merge(args: argparse.Namespace) -> None:
273 """Merge a proposal.
274
275 Merges immediately — no confirmation dialog. The source branch is deleted
276 by default; pass ``--no-delete-branch`` to keep it.
277
278 Merge strategies: ``overlay`` (default), ``weave``, ``replay``, ``selective``.
279 Commit history styles: ``merge`` (default), ``squash``, ``rebase``.
280
281 The hub may return HTTP 200 with ``{"merged": false}`` when it refuses the
282 merge (e.g. conflict, branch-protection rule). This command treats that
283 as a failure in **both** text and JSON modes — exit code 3 is returned so
284 that agent pipelines can reliably use the exit code to detect success::
285
286 muse hub proposal merge af54753d --json && echo "merged" || echo "conflict"
287
288 JSON output is the raw API response merged with the standard envelope;
289 human-readable text goes to stderr.
290
291 Agent quickstart
292 ----------------
293 ::
294
295 muse hub proposal merge af54753d --strategy squash --json
296 # → {"muse_version": "...", ..., "merged": true, "mergeCommitId": "..."}
297
298 # Safe pipeline — exit code 3 on merge failure, even with --json:
299 PROPOSAL_RESULT=$(muse hub proposal merge af54753d --json) || exit 1
300 echo "$PROPOSAL_RESULT" | jq '.mergeCommitId'
301
302 Exit codes
303 ----------
304 0 Merged successfully.
305 1 Prefix not found, ambiguous, or not authenticated.
306 2 Not inside a Muse repository.
307 3 Merge rejected by hub (conflict, branch protection, etc.) or API error.
308 """
309 proposal_id: str = args.proposal_id
310 strategy: str = args.strategy
311 history: str = args.history
312 delete_branch: bool = args.delete_branch
313 json_output: bool = args.json_output
314
315 elapsed = start_timer()
316 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
317 repo_id = _resolve_repo_id(hub_url, identity)
318 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id)
319
320 payload: _HubPayload = {
321 "mergeStrategy": strategy,
322 "commitHistory": history,
323 "deleteBranch": delete_branch,
324 }
325 data = _hub_api(
326 hub_url, identity, "POST",
327 f"/api/repos/{repo_id}/proposals/{proposal_id}/merge",
328 body=payload,
329 )
330
331 merged: bool = bool(data.get("merged", False))
332
333 if json_output:
334 print(json.dumps({**make_envelope(elapsed), **data}))
335 # Exit nonzero even in JSON mode so agent pipelines can rely on the
336 # exit code — a hub-side merge rejection must never silently exit 0.
337 if not merged:
338 raise SystemExit(ExitCode.INTERNAL_ERROR)
339 return
340
341 commit_id: str = str(data.get("mergeCommitId", "")) or ""
342 if merged:
343 sha = sanitize_display(commit_id) if commit_id else "(no SHA)"
344 print(
345 f"✅ Proposal {sanitize_display(proposal_id)} merged → {sha}",
346 file=sys.stderr,
347 )
348 if delete_branch:
349 print(" Source branch deleted.", file=sys.stderr)
350 else:
351 msg = str(data.get("message", "unknown error"))
352 print(f"❌ Merge failed: {sanitize_display(msg)}", file=sys.stderr)
353 raise SystemExit(ExitCode.INTERNAL_ERROR)
354
355 def run_proposal_comment_list(args: argparse.Namespace) -> None:
356 """List all comments on a proposal, threaded by parent.
357
358 Each top-level comment includes its direct replies nested under it.
359
360 JSON output is the raw API response merged with the standard envelope.
361 Human-readable text goes to stderr.
362
363 Agent quickstart
364 ----------------
365 ::
366
367 muse hub proposal comment list af54753d --json
368 muse hub proposal comment list af54753d --json | jq '.comments[].author'
369
370 Exit codes
371 ----------
372 0 Success.
373 1 Auth error or proposal not found.
374 2 Not inside a Muse repository.
375 3 API error.
376 """
377 proposal_id_or_prefix: str = args.proposal_id
378 json_output: bool = args.json_output
379
380 elapsed = start_timer()
381 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
382 repo_id = _resolve_repo_id(hub_url, identity)
383 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix)
384
385 data = _hub_api(
386 hub_url, identity, "GET",
387 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
388 )
389
390 if json_output:
391 print(json.dumps({**make_envelope(elapsed), **data}))
392 return
393
394 comments = data.get("comments", [])
395 total = data.get("total", len(comments))
396 if not comments:
397 print(f"No comments on proposal {sanitize_display(proposal_id)}.", file=sys.stderr)
398 return
399 print(f"Comments ({total}):", file=sys.stderr)
400 for c in comments:
401 author = sanitize_display(str(c.get("author", "")))
402 body = sanitize_display(str(c.get("body", "")))[:80]
403 print(f" [{author}] {body}")
404 for r in c.get("replies", []):
405 r_author = sanitize_display(str(r.get("author", "")))
406 r_body = sanitize_display(str(r.get("body", "")))[:70]
407 print(f" ↳ [{r_author}] {r_body}")
408
409 def run_proposal_reviewer_request(args: argparse.Namespace) -> None:
410 """Request one or more reviewers on a proposal.
411
412 Reviewer handles are passed as positional arguments.
413
414 JSON output is the raw API response merged with the standard envelope.
415 Human-readable text goes to stderr.
416
417 Agent quickstart
418 ----------------
419 ::
420
421 muse hub proposal reviewer request af54753d alice bob --json
422 muse hub proposal reviewer request af54753d alice --json | jq '.reviews[].state'
423
424 Exit codes
425 ----------
426 0 Success.
427 1 Auth error or not authorized.
428 2 Not inside a Muse repository.
429 3 API error.
430 """
431 proposal_id_or_prefix: str = args.proposal_id
432 reviewers: list[str] = args.reviewers
433 json_output: bool = args.json_output
434
435 elapsed = start_timer()
436 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
437 repo_id = _resolve_repo_id(hub_url, identity)
438 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix)
439
440 data = _hub_api(
441 hub_url, identity, "POST",
442 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
443 body={"reviewers": reviewers},
444 )
445
446 if json_output:
447 print(json.dumps({**make_envelope(elapsed), **data}))
448 return
449
450 reviews = data.get("reviews", [])
451 print(f"✅ Reviewers requested ({len(reviews)} total):", file=sys.stderr)
452 for rv in reviews:
453 handle = sanitize_display(str(rv.get("reviewerUsername", rv.get("reviewer", ""))))
454 state = sanitize_display(str(rv.get("state", "")))
455 print(f" {handle:<32} {state}")
456
457 def run_proposal_reviewer_remove(args: argparse.Namespace) -> None:
458 """Remove a pending reviewer from a proposal.
459
460 Only pending review requests can be removed — submitted reviews are immutable.
461
462 JSON output is the raw API response merged with the standard envelope.
463 Human-readable text goes to stderr.
464
465 Agent quickstart
466 ----------------
467 ::
468
469 muse hub proposal reviewer remove af54753d alice --json
470 muse hub proposal reviewer remove af54753d alice --json | jq '.reviews | length'
471
472 Exit codes
473 ----------
474 0 Success.
475 1 Auth error, reviewer not found, or review already submitted.
476 2 Not inside a Muse repository.
477 3 API error.
478 """
479 proposal_id_or_prefix: str = args.proposal_id
480 reviewer: str = args.reviewer
481 json_output: bool = args.json_output
482
483 elapsed = start_timer()
484 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
485 repo_id = _resolve_repo_id(hub_url, identity)
486 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix)
487
488 data = _hub_api(
489 hub_url, identity, "DELETE",
490 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/{reviewer}",
491 )
492
493 if json_output:
494 print(json.dumps({**make_envelope(elapsed), **data}))
495 return
496
497 reviews = data.get("reviews", [])
498 print(
499 f"✅ Reviewer {sanitize_display(reviewer)} removed "
500 f"({len(reviews)} reviewers remaining).",
501 file=sys.stderr,
502 )
503
504 def run_proposal_review_list(args: argparse.Namespace) -> None:
505 """List all reviews for a proposal.
506
507 Includes pending assignments and submitted reviews (``approved``,
508 ``changes_requested``, ``dismissed``). Pass ``--state`` to filter.
509
510 JSON output is the raw API response merged with the standard envelope.
511 Human-readable text goes to stderr.
512
513 Agent quickstart
514 ----------------
515 ::
516
517 muse hub proposal review list af54753d --json
518 muse hub proposal review list af54753d --state approved --json | jq '.reviews[].reviewerUsername'
519
520 Exit codes
521 ----------
522 0 Success.
523 1 Auth error or proposal not found.
524 2 Not inside a Muse repository.
525 3 API error.
526 """
527 proposal_id_or_prefix: str = args.proposal_id
528 state: str | None = getattr(args, "state", None) or None
529 json_output: bool = args.json_output
530
531 elapsed = start_timer()
532 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
533 repo_id = _resolve_repo_id(hub_url, identity)
534 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id_or_prefix)
535
536 path = f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews"
537 if state:
538 path += f"?state={state}"
539 data = _hub_api(hub_url, identity, "GET", path)
540
541 if json_output:
542 print(json.dumps({**make_envelope(elapsed), **data}))
543 return
544
545 reviews = data.get("reviews", [])
546 total = data.get("total", len(reviews))
547 if not reviews:
548 print(f"No reviews on proposal {sanitize_display(proposal_id)}.", file=sys.stderr)
549 return
550 print(f"Reviews ({total}):", file=sys.stderr)
551 for rv in reviews:
552 handle = sanitize_display(str(rv.get("reviewerUsername", rv.get("reviewer", ""))))
553 s = sanitize_display(str(rv.get("state", "")))
554 print(f" {handle:<32} {s}")
555
556 def run_proposal_comment_create(args: argparse.Namespace) -> None:
557 """Post a comment on a merge proposal on MuseHub.
558
559 The comment body is supplied via ``--body`` (inline) or ``--body-file``
560 (path to a file whose contents become the body). At least one is required.
561
562 JSON output is the raw API response merged with the standard envelope.
563 Human-readable text goes to stderr.
564
565 Agent quickstart
566 ----------------
567 ::
568
569 muse hub proposal comment create af54753d --body 'LGTM' --json
570 # → {"muse_version": "...", ..., "commentId": "...", "author": "...", ...}
571
572 JSON output keys (from hub): ``commentId``, ``proposalId``, ``author``,
573 ``body``, ``createdAt``.
574
575 Exit codes
576 ----------
577 0 Comment posted.
578 1 Validation or auth error.
579 2 Not inside a Muse repository.
580 3 API error.
581 """
582 body: str = _resolve_body(args)
583 if not body.strip():
584 print("❌ --body or --body-file must not be empty.", file=sys.stderr)
585 raise SystemExit(ExitCode.USER_ERROR)
586
587 elapsed = start_timer()
588 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
589 repo_id = _resolve_repo_id(hub_url, identity)
590
591 payload = {"body": body}
592 data = _hub_api(hub_url, identity, "POST",
593 f"/api/repos/{repo_id}/proposals/{args.proposal_id}/comments",
594 body=payload)
595
596 if args.json_output:
597 print(json.dumps({**make_envelope(elapsed), **data}))
598 return
599
600 print(f"✅ Comment posted on proposal {args.proposal_id[:8]}.", file=sys.stderr)
601
602 def run_proposal_update(args: argparse.Namespace) -> None:
603 """Partially update a proposal (title, body, type, or strategy).
604
605 At least one of ``--title``, ``--body`` / ``--body-file``, ``--type``, or
606 ``--strategy`` must be supplied. Only the fields you pass are changed;
607 the rest are left untouched.
608
609 JSON output is the raw API response merged with the standard envelope.
610 Human-readable confirmation goes to stderr.
611
612 Agent quickstart
613 ----------------
614 ::
615
616 muse hub proposal update af54753d --title "New title" --json
617 muse hub proposal update af54753d --type canonical_release --json
618 muse hub proposal update af54753d --body-file notes.md --json
619
620 Exit codes
621 ----------
622 0 Proposal updated.
623 1 Validation error (no fields supplied, unknown type/strategy).
624 2 Not inside a Muse repository.
625 3 API / network error.
626 """
627 title: str | None = getattr(args, "title", None)
628 body_inline: str | None = getattr(args, "body", None)
629 body_file: str | None = getattr(args, "body_file", None)
630 proposal_type: str | None = getattr(args, "proposal_type", None)
631 merge_strategy: str | None = getattr(args, "merge_strategy", None)
632 json_output: bool = args.json_output
633
634 body: str | None = None
635 if body_file is not None:
636 import sys as _sys
637 if body_file == "-":
638 body = _sys.stdin.read()
639 else:
640 try:
641 body = open(body_file).read()
642 except OSError as exc:
643 print(f"❌ Cannot read --body-file: {exc}", file=sys.stderr)
644 raise SystemExit(ExitCode.USER_ERROR) from exc
645 elif body_inline is not None:
646 body = body_inline
647
648 payload: dict[str, str] = {}
649 if title is not None:
650 payload["title"] = title
651 if body is not None:
652 payload["body"] = body
653 if proposal_type is not None:
654 payload["proposal_type"] = proposal_type
655 if merge_strategy is not None:
656 payload["merge_strategy"] = merge_strategy
657
658 if not payload:
659 print(
660 "❌ At least one of --title, --body, --body-file, --type, or --strategy is required.",
661 file=sys.stderr,
662 )
663 raise SystemExit(ExitCode.USER_ERROR)
664
665 elapsed = start_timer()
666 try:
667 hub_url, identity = _get_hub_and_identity(hub_url_override=getattr(args, "hub", None))
668 repo_id = _resolve_repo_id(hub_url, identity)
669 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, args.proposal_id)
670 data = _hub_api(
671 hub_url, identity, "PATCH",
672 f"/api/repos/{repo_id}/proposals/{proposal_id}",
673 body=payload,
674 )
675 except Exception as exc:
676 print(f"❌ API error: {exc}", file=sys.stderr)
677 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
678
679 if json_output:
680 print(json.dumps({**make_envelope(elapsed), **data}))
681 return
682
683 short = str(data.get("proposalId", args.proposal_id))[:8]
684 print(f"✅ Proposal {short} updated.", file=sys.stderr)
685
686
687 def run_proposal_close(args: argparse.Namespace) -> None:
688 """Close an open proposal without merging.
689
690 Issues ``POST .../proposals/{id}/close`` on MuseHub.
691 Returns 409 if the proposal is already closed or merged.
692
693 Agent quickstart
694 ----------------
695 ::
696
697 muse hub proposal close af54753d --json
698 # → {"muse_version": "...", ..., "state": "closed", ...}
699
700 Exit codes
701 ----------
702 0 Closed successfully.
703 1 Prefix not found, ambiguous, or not authenticated.
704 2 Not inside a Muse repository.
705 3 Proposal already closed/merged, or API error.
706 """
707 proposal_id: str = args.proposal_id
708 json_output: bool = args.json_output
709
710 elapsed = start_timer()
711 hub_url, identity = _get_hub_and_identity(hub_url_override=args.hub)
712 repo_id = _resolve_repo_id(hub_url, identity)
713 proposal_id = _resolve_proposal_id(hub_url, identity, repo_id, proposal_id)
714
715 data = _hub_api(
716 hub_url, identity, "POST",
717 f"/api/repos/{repo_id}/proposals/{proposal_id}/close",
718 )
719
720 if json_output:
721 print(json.dumps({**make_envelope(elapsed), **data}))
722 return
723
724 short = sanitize_display(proposal_id[:8])
725 print(f"✅ Proposal {short} closed.", file=sys.stderr)
726
727
728 def run_proposal_review_submit(args: argparse.Namespace) -> None:
729 """Submit an approve, request-changes, or comment review on a proposal.
730
731 Verdicts: ``approve``, ``request_changes``, ``comment``.
732 A ``--body`` is optional for ``approve`` and required for ``request_changes``.
733
734 JSON output is the raw API response merged with the standard envelope.
735 Human-readable text goes to stderr.
736
737 Agent quickstart
738 ----------------
739 ::
740
741 muse hub proposal review submit af54753d --verdict approve --json
742 muse hub proposal review submit af54753d --verdict request_changes --body 'Fix X' --json
743 # → {"muse_version": "...", ..., "reviewId": "...", "verdict": "..."}
744
745 JSON output keys (from hub): ``reviewId``, ``proposalId``, ``author``,
746 ``verdict``, ``body``, ``createdAt``.
747
748 Exit codes
749 ----------
750 0 Review submitted.
751 1 Auth error.
752 2 Not inside a Muse repository.
753 3 API error.
754 """
755 elapsed = start_timer()
756 hub_url, identity = _get_hub_and_identity(hub_url_override=_resolve_hub_override(args))
757 repo_id = _resolve_repo_id(hub_url, identity)
758
759 payload = {"verdict": args.verdict, "body": _resolve_body(args)}
760 data = _hub_api(hub_url, identity, "POST",
761 f"/api/repos/{repo_id}/proposals/{args.proposal_id}/reviews",
762 body=payload)
763
764 if args.json_output:
765 print(json.dumps({**make_envelope(elapsed), **data}))
766 return
767
768 print(f"✅ Review ({args.verdict}) submitted on proposal {args.proposal_id[:8]}.", file=sys.stderr)
769
770 def register(subs: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
771 """Register proposals subcommands."""
772 # ── proposal ──────────────────────────────────────────────────────────────
773 proposal_p = subs.add_parser(
774 "proposal",
775 help="Manage proposals on MuseHub.",
776 formatter_class=argparse.RawDescriptionHelpFormatter,
777 )
778 proposal_subs = proposal_p.add_subparsers(dest="proposal_subcommand", metavar="PROPOSAL_COMMAND")
779 proposal_subs.required = True
780
781 proposal_create_p = proposal_subs.add_parser(
782 "create",
783 help="Open a new proposal.",
784 description=(
785 "Open a new proposal on MuseHub.\n\n"
786 "Uses the current branch as the source if --from-branch is omitted.\n"
787 "Both Muse-native flags (--from-branch / --to-branch) and familiar\n"
788 "aliases (--head / --base) are accepted.\n\n"
789 f"Title must be non-empty and ≤ {_MAX_PROPOSAL_TITLE_LEN} characters.\n"
790 "Detached HEAD is rejected — pass --from-branch explicitly.\n\n"
791 "Agent quickstart:\n"
792 " muse hub proposal create --title 'feat: x' --from-branch feat/x --json\n"
793 " PROPOSAL=$(muse hub proposal create --title 'feat: x' --json)\n"
794 " echo \"$PROPOSAL\" | jq '.proposalId'\n\n"
795 "Exit codes: 0 created, 1 validation/branch error, 2 not in repo, 3 API error."
796 ),
797 formatter_class=argparse.RawDescriptionHelpFormatter,
798 )
799 proposal_create_p.add_argument(
800 "--hub", dest="hub", default=None, metavar="URL",
801 help="Override the hub URL from config.",
802 )
803 proposal_create_p.add_argument("--title", "-t", required=True, help="Proposal title.")
804 proposal_create_p.add_argument("--body", "-b", default="", help="Proposal body / description (inline text).")
805 proposal_create_p.add_argument(
806 "--body-file", default=None, metavar="PATH", dest="body_file",
807 help="Read proposal body from PATH. Pass '-' for stdin. Takes precedence over --body.",
808 )
809 proposal_create_p.add_argument(
810 "--from-branch", "--head", "-f", dest="from_branch", default=None,
811 help=(
812 "Source branch (default: current branch). "
813 "--head is accepted as a git-familiar alias."
814 ),
815 )
816 proposal_create_p.add_argument(
817 "--to-branch", "--base", dest="to_branch", default="dev",
818 help="Target branch (default: dev). --base is accepted as a git-familiar alias.",
819 )
820 proposal_create_p.add_argument(
821 "--json", "-j", action="store_true", dest="json_output",
822 help="Emit JSON with the created proposal.",
823 )
824 proposal_create_p.set_defaults(func=run_proposal_create)
825
826 proposal_list_p = proposal_subs.add_parser(
827 "list",
828 help="List proposals.",
829 description=(
830 "List proposals on the configured MuseHub.\n\n"
831 "Filters by state (default: open). --limit caps the number of results.\n"
832 "Use --state all to see every proposal regardless of state.\n"
833 "Use --verbose to also show author and creation date per proposal.\n\n"
834 "Agent quickstart:\n"
835 " muse hub proposal list --state open --json\n"
836 " muse hub proposal list --state all -n 100 --json | jq '.[] | .proposalId'\n\n"
837 "Exit codes: 0 success, 1 not authenticated, 2 not in repo, 3 API error."
838 ),
839 formatter_class=argparse.RawDescriptionHelpFormatter,
840 )
841 proposal_list_p.add_argument(
842 "--hub", dest="hub", default=None, metavar="URL",
843 help="Override the hub URL from config (e.g. http://host.docker.internal:10003/owner/repo).",
844 )
845 proposal_list_p.add_argument(
846 "--state", default="open", choices=["open", "merged", "closed", "all"],
847 help="Filter by state: open (default), merged, closed, all.",
848 )
849 proposal_list_p.add_argument(
850 "--limit", "-n", type=int, default=20,
851 help="Maximum number of proposals to return (default: 20).",
852 )
853 proposal_list_p.add_argument(
854 "--verbose", "-v", action="store_true", dest="verbose",
855 help="Show author and creation date for each proposal.",
856 )
857 proposal_list_p.add_argument(
858 "--json", "-j", action="store_true", dest="json_output",
859 help="Emit JSON to stdout instead of human-readable output.",
860 )
861 proposal_list_p.set_defaults(func=run_proposal_list)
862
863 proposal_merge_p = proposal_subs.add_parser(
864 "merge",
865 help="Merge a proposal.",
866 description=(
867 "Merge a proposal immediately — no confirmation dialog.\n\n"
868 "The source branch is deleted by default; pass --no-delete-branch\n"
869 "to keep it.\n\n"
870 "Content merge strategies (--strategy): overlay (default), weave, replay, selective.\n"
871 "Commit history styles (--history): merge (default), squash, rebase.\n\n"
872 "Exit code 3 is returned on merge rejection in BOTH text and JSON mode\n"
873 "so agent pipelines can always rely on the exit code:\n"
874 " muse hub proposal merge af54753d --json && echo ok || echo conflict\n\n"
875 "Agent quickstart:\n"
876 " muse hub proposal merge af54753d --strategy overlay --history merge --json\n"
877 " # → {\"merged\": true, \"mergeCommitId\": \"...\", ...}\n\n"
878 "Exit codes: 0 merged, 1 not found/not authenticated,\n"
879 " 2 not in repo, 3 merge rejected or API error."
880 ),
881 formatter_class=argparse.RawDescriptionHelpFormatter,
882 )
883 proposal_merge_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
884 proposal_merge_p.add_argument(
885 "--hub", dest="hub", default=None, metavar="URL",
886 help="Override the hub URL from config.",
887 )
888 proposal_merge_p.add_argument(
889 "--strategy", default="overlay",
890 choices=["overlay", "weave", "replay", "selective",
891 "state_overlay", "state_weave", "state_rebase", "domain_selective"],
892 help="Content merge strategy (default: overlay).",
893 )
894 proposal_merge_p.add_argument(
895 "--history", default="merge",
896 choices=["merge", "squash", "rebase"],
897 help="Commit history style (default: merge).",
898 )
899 proposal_merge_p.add_argument(
900 "--no-delete-branch", dest="delete_branch", action="store_false",
901 help="Do not delete the source branch after merging.",
902 )
903 proposal_merge_p.add_argument(
904 "--json", "-j", action="store_true", dest="json_output",
905 help="Emit JSON with the proposal merge result.",
906 )
907 proposal_merge_p.set_defaults(func=run_proposal_merge, delete_branch=True)
908
909 proposal_read_p = proposal_subs.add_parser(
910 "read",
911 help="Read a single proposal.",
912 description=(
913 "Read a single proposal by ID.\n\n"
914 "Accepts the full ID or an 8-character prefix. Prefix resolution\n"
915 f"fetches the most recent {_PROPOSAL_PREFIX_RESOLVE_LIMIT} proposals — use the full ID\n"
916 "on large repositories to avoid ambiguity.\n\n"
917 "Text output shows state, title, ID, branches, author, date, and\n"
918 f"up to {_MAX_PROPOSAL_BODY_LINES} body lines (truncation hint printed when exceeded).\n"
919 "JSON output is the raw API response (camelCase field names).\n\n"
920 "Agent quickstart:\n"
921 " muse hub proposal read af54753d --json | jq '.state'\n"
922 " muse hub proposal read af54753d --json | jq '{state,title,author}'\n\n"
923 "Exit codes: 0 found, 1 not found/ambiguous, 2 not in repo, 3 API error."
924 ),
925 formatter_class=argparse.RawDescriptionHelpFormatter,
926 )
927 proposal_read_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
928 proposal_read_p.add_argument(
929 "--hub", dest="hub", default=None, metavar="URL",
930 help="Override the hub URL from config.",
931 )
932 proposal_read_p.add_argument(
933 "--json", "-j", action="store_true", dest="json_output", help="Emit JSON.",
934 )
935 proposal_read_p.set_defaults(func=run_proposal_read)
936
937 # ── proposal comment ──────────────────────────────────────────────────────
938 proposal_comment_p = proposal_subs.add_parser(
939 "comment",
940 help="Manage proposal comments.",
941 description="Manage review comments on a merge proposal.",
942 formatter_class=argparse.RawDescriptionHelpFormatter,
943 )
944 proposal_comment_subs = proposal_comment_p.add_subparsers(
945 dest="proposal_comment_subcommand", metavar="COMMENT_COMMAND",
946 )
947 proposal_comment_subs.required = True
948
949 proposal_comment_list_p = proposal_comment_subs.add_parser(
950 "list", help="List all comments on a proposal (threaded).",
951 )
952 proposal_comment_list_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
953 proposal_comment_list_p.add_argument("--hub", dest="hub", default=None, metavar="URL")
954 proposal_comment_list_p.add_argument("--json", "-j", action="store_true", dest="json_output")
955 proposal_comment_list_p.set_defaults(func=run_proposal_comment_list)
956
957 proposal_comment_create_p = proposal_comment_subs.add_parser(
958 "create", help="Post a new comment on a proposal.",
959 description=(
960 "Post a Markdown comment on a merge proposal.\n\n"
961 " muse hub proposal comment create <proposal_id> --body 'LGTM'\n"
962 " muse hub proposal comment create <proposal_id> --body 'See inline' --json\n\n"
963 "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error."
964 ),
965 formatter_class=argparse.RawDescriptionHelpFormatter,
966 )
967 proposal_comment_create_p.add_argument("proposal_id", help="ID of the proposal.")
968 proposal_comment_create_p.add_argument("--hub", dest="hub", default=None, metavar="URL",
969 help="Override the hub URL from config.")
970 proposal_comment_create_p.add_argument("--repo", dest="repo", default=None, metavar="OWNER/REPO",
971 help="Specify repo as owner/repo.")
972 proposal_comment_create_p.add_argument("--body", "-b", default=None,
973 help="Comment body (Markdown, inline text).")
974 proposal_comment_create_p.add_argument(
975 "--body-file", default=None, metavar="PATH", dest="body_file",
976 help="Read comment body from PATH. Pass '-' for stdin. Takes precedence over --body.",
977 )
978 proposal_comment_create_p.add_argument("--json", "-j", action="store_true", dest="json_output",
979 help="Emit JSON output.")
980 proposal_comment_create_p.set_defaults(func=run_proposal_comment_create)
981
982 proposal_comment_p.set_defaults(func=lambda a: proposal_comment_p.print_help())
983
984 # ── proposal reviewer ─────────────────────────────────────────────────────
985 proposal_reviewer_p = proposal_subs.add_parser(
986 "reviewer",
987 help="Manage proposal reviewers.",
988 description="Request or remove reviewers on a merge proposal.",
989 formatter_class=argparse.RawDescriptionHelpFormatter,
990 )
991 proposal_reviewer_subs = proposal_reviewer_p.add_subparsers(
992 dest="proposal_reviewer_subcommand", metavar="REVIEWER_COMMAND",
993 )
994 proposal_reviewer_subs.required = True
995
996 proposal_reviewer_request_p = proposal_reviewer_subs.add_parser(
997 "request", help="Request reviewers on a proposal.",
998 )
999 proposal_reviewer_request_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
1000 proposal_reviewer_request_p.add_argument("reviewers", nargs="+", help="MSign handles to request.")
1001 proposal_reviewer_request_p.add_argument("--hub", dest="hub", default=None, metavar="URL")
1002 proposal_reviewer_request_p.add_argument("--json", "-j", action="store_true", dest="json_output")
1003 proposal_reviewer_request_p.set_defaults(func=run_proposal_reviewer_request)
1004
1005 proposal_reviewer_remove_p = proposal_reviewer_subs.add_parser(
1006 "remove", help="Remove a pending reviewer from a proposal.",
1007 )
1008 proposal_reviewer_remove_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
1009 proposal_reviewer_remove_p.add_argument("reviewer", help="MSign handle of the reviewer to remove.")
1010 proposal_reviewer_remove_p.add_argument("--hub", dest="hub", default=None, metavar="URL")
1011 proposal_reviewer_remove_p.add_argument("--json", "-j", action="store_true", dest="json_output")
1012 proposal_reviewer_remove_p.set_defaults(func=run_proposal_reviewer_remove)
1013
1014 proposal_reviewer_p.set_defaults(func=lambda a: proposal_reviewer_p.print_help())
1015
1016 # ── proposal review ───────────────────────────────────────────────────────
1017 proposal_review_p = proposal_subs.add_parser(
1018 "review",
1019 help="Manage proposal reviews.",
1020 description="List or filter formal reviews on a merge proposal.",
1021 formatter_class=argparse.RawDescriptionHelpFormatter,
1022 )
1023 proposal_review_subs = proposal_review_p.add_subparsers(
1024 dest="proposal_review_subcommand", metavar="REVIEW_COMMAND",
1025 )
1026 proposal_review_subs.required = True
1027
1028 proposal_review_list_p = proposal_review_subs.add_parser(
1029 "list", help="List all reviews on a proposal.",
1030 )
1031 proposal_review_list_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
1032 proposal_review_list_p.add_argument(
1033 "--state",
1034 choices=["pending", "approved", "changes_requested", "dismissed"],
1035 default=None,
1036 help="Filter by review state.",
1037 )
1038 proposal_review_list_p.add_argument("--hub", dest="hub", default=None, metavar="URL")
1039 proposal_review_list_p.add_argument("--json", "-j", action="store_true", dest="json_output")
1040 proposal_review_list_p.set_defaults(func=run_proposal_review_list)
1041
1042 proposal_review_submit_p = proposal_review_subs.add_parser(
1043 "submit", help="Submit a review (approve, request-changes, or comment).",
1044 description=(
1045 "Submit an approve, request-changes, or comment review on a proposal.\n\n"
1046 " muse hub proposal review submit <proposal_id> --verdict approve\n"
1047 " muse hub proposal review submit <proposal_id> --verdict request_changes --body 'Fix X'\n"
1048 " muse hub proposal review submit <proposal_id> --verdict comment --body 'Notes' --json\n\n"
1049 "Exit codes: 0 success, 1 auth error, 2 not in repo, 3 API error."
1050 ),
1051 formatter_class=argparse.RawDescriptionHelpFormatter,
1052 )
1053 proposal_review_submit_p.add_argument("proposal_id", help="ID of the proposal.")
1054 proposal_review_submit_p.add_argument("--hub", dest="hub", default=None, metavar="URL",
1055 help="Override the hub URL from config.")
1056 proposal_review_submit_p.add_argument("--repo", dest="repo", default=None, metavar="OWNER/REPO",
1057 help="Specify repo as owner/repo.")
1058 proposal_review_submit_p.add_argument(
1059 "--verdict", required=True,
1060 choices=["approve", "request_changes", "comment"],
1061 help="Review verdict.",
1062 )
1063 proposal_review_submit_p.add_argument("--body", "-b", default="",
1064 help="Review body (Markdown, inline text).")
1065 proposal_review_submit_p.add_argument(
1066 "--body-file", default=None, metavar="PATH", dest="body_file",
1067 help="Read review body from PATH. Pass '-' for stdin. Takes precedence over --body.",
1068 )
1069 proposal_review_submit_p.add_argument("--json", "-j", action="store_true", dest="json_output",
1070 help="Emit JSON output.")
1071 proposal_review_submit_p.set_defaults(func=run_proposal_review_submit)
1072
1073 proposal_review_p.set_defaults(func=lambda a: proposal_review_p.print_help())
1074
1075 # ── proposal update ───────────────────────────────────────────────────────
1076 _PROPOSAL_TYPES = [
1077 "state_merge", "stem_integration", "midi_evolution",
1078 "payment_settlement", "agent_delegation", "identity_transition",
1079 "canonical_release",
1080 ]
1081 _MERGE_STRATEGIES = ["state_overlay", "state_rebase", "state_squash"]
1082
1083 proposal_update_p = proposal_subs.add_parser(
1084 "update",
1085 help="Partially update a proposal (title, body, type, strategy).",
1086 description=(
1087 "Partially update an open merge proposal on MuseHub.\n\n"
1088 "Only the fields you supply are changed — the rest are left untouched.\n"
1089 "At least one flag is required.\n\n"
1090 "Agent quickstart:\n"
1091 " muse hub proposal update <id> --title 'New title' --json\n"
1092 " muse hub proposal update <id> --type canonical_release --json\n"
1093 " muse hub proposal update <id> --body-file notes.md --json\n\n"
1094 "Exit codes: 0 updated, 1 validation error, 2 not in repo, 3 API error."
1095 ),
1096 formatter_class=argparse.RawDescriptionHelpFormatter,
1097 )
1098 proposal_update_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
1099 proposal_update_p.add_argument(
1100 "--hub", dest="hub", default=None, metavar="URL",
1101 help="Override the hub URL from config.",
1102 )
1103 proposal_update_p.add_argument("--title", "-t", default=None, help="New proposal title.")
1104 proposal_update_p.add_argument("--body", "-b", default=None, help="New proposal body (inline Markdown).")
1105 proposal_update_p.add_argument(
1106 "--body-file", default=None, metavar="PATH", dest="body_file",
1107 help="Read new body from PATH. Pass '-' for stdin. Takes precedence over --body.",
1108 )
1109 proposal_update_p.add_argument(
1110 "--type", dest="proposal_type", default=None, choices=_PROPOSAL_TYPES,
1111 metavar="TYPE", help=f"New proposal type. One of: {', '.join(_PROPOSAL_TYPES)}.",
1112 )
1113 proposal_update_p.add_argument(
1114 "--strategy", dest="merge_strategy", default=None, choices=_MERGE_STRATEGIES,
1115 metavar="STRATEGY", help=f"New merge strategy. One of: {', '.join(_MERGE_STRATEGIES)}.",
1116 )
1117 proposal_update_p.add_argument(
1118 "--json", "-j", action="store_true", dest="json_output",
1119 help="Emit JSON with the updated proposal.",
1120 )
1121 proposal_update_p.set_defaults(func=run_proposal_update)
1122
1123 # ── proposal close ────────────────────────────────────────────────────────
1124 proposal_close_p = proposal_subs.add_parser(
1125 "close",
1126 help="Close an open proposal without merging.",
1127 description=(
1128 "Close an open proposal without merging it.\n\n"
1129 "Issues POST .../proposals/{id}/close on MuseHub.\n"
1130 "Returns 409 if the proposal is already closed or merged.\n\n"
1131 "Agent quickstart:\n"
1132 " muse hub proposal close af54753d --json\n"
1133 " # → {\"state\": \"closed\", ...}\n\n"
1134 "Exit codes: 0 closed, 1 not found/not authenticated,\n"
1135 " 2 not in repo, 3 already closed/merged or API error."
1136 ),
1137 formatter_class=argparse.RawDescriptionHelpFormatter,
1138 )
1139 proposal_close_p.add_argument("proposal_id", help="Proposal ID (full ID or 8-char prefix).")
1140 proposal_close_p.add_argument(
1141 "--hub", dest="hub", default=None, metavar="URL",
1142 help="Override the hub URL from config.",
1143 )
1144 proposal_close_p.add_argument(
1145 "--json", "-j", action="store_true", dest="json_output",
1146 help="Emit JSON with the closed proposal.",
1147 )
1148 proposal_close_p.set_defaults(func=run_proposal_close)
1149
1150 proposal_p.set_defaults(func=lambda a: proposal_p.print_help())
1151
File History 6 commits
sha256:371209e571fc5fc0010114aaa8f272b15179a2245abd7f62320fa700fbe2a60b feat: add url and base64 id to hub issue/proposal/repo crea… Sonnet 4.6 patch 2 days ago
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 2 days ago
sha256:99451767674c70e97323b61d5ef248ebe91530a91c2ab5902c2bb3e4acf250dd Run in a fresh repo so no stale MERGE_STATE.json can bleed in Human patch 3 days ago
sha256:0ab1022a97637701da7c18808e65556a5c774a1572b42e02599ff55efaf69ef4 feat: route merge.py through STRATEGY_MAP; update hub propo… Sonnet 4.6 patch 3 days ago
sha256:f1f585ee9ca4e1ada936668c1b14f42f961a1fa78a2c033b643595f9c1bf9ac7 fixes for proposal flow Human patch 8 days ago
sha256:61ca5239532bb0f2daa1c2de73fdebe4d09ccbf2b13272d4ee7b197cc8e4dd58 fix proposal merge flags Human patch 9 days ago