buildai-cli 0.3.89__tar.gz → 0.3.91__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/PKG-INFO +1 -1
  2. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/ego_frame_search.py +76 -0
  3. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/spec.py +127 -1
  4. buildai_cli-0.3.91/cli/commands/spec_pr.py +303 -0
  5. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/pyproject.toml +1 -1
  6. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/.gitignore +0 -0
  7. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/AGENTS.md +0 -0
  8. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/CLAUDE.md +0 -0
  9. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/buildai_bootstrap.py +0 -0
  10. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/__init__.py +0 -0
  11. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/_has_core.py +0 -0
  12. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/auth_local.py +0 -0
  13. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/__init__.py +0 -0
  14. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/api_proxy.py +0 -0
  15. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/auth.py +0 -0
  16. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/__init__.py +0 -0
  17. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/broker.py +0 -0
  18. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/common.py +0 -0
  19. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/migrate.py +0 -0
  20. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/query.py +0 -0
  21. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/schema.py +0 -0
  22. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/status.py +0 -0
  23. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/db/tunnel.py +0 -0
  24. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/dev.py +0 -0
  25. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/doctor.py +0 -0
  26. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/egoexo.py +0 -0
  27. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/gigcamera.py +0 -0
  28. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/grid.py +0 -0
  29. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/ingest.py +0 -0
  30. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/ingest_docs.py +0 -0
  31. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/commands/processing.py +0 -0
  32. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/config.py +0 -0
  33. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/console.py +0 -0
  34. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/context.py +0 -0
  35. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/db_broker.py +0 -0
  36. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/guard.py +0 -0
  37. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/internal_api.py +0 -0
  38. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/main.py +0 -0
  39. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/nl_query/__init__.py +0 -0
  40. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/nl_query/dataset_tools.py +0 -0
  41. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/ops_init.py +0 -0
  42. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/output.py +0 -0
  43. {buildai_cli-0.3.89 → buildai_cli-0.3.91}/cli/pagination.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildai-cli
3
- Version: 0.3.89
3
+ Version: 0.3.91
4
4
  Summary: Build AI CLI (Typer)
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27.0
@@ -567,3 +567,79 @@ def queue_missing_sampled_frames(
567
567
  render(manifest, format=format)
568
568
 
569
569
  asyncio.run(run())
570
+
571
+
572
+ @app.command("queue-missing-demian-labels")
573
+ def queue_missing_demian_labels(
574
+ ctx: typer.Context,
575
+ corpus_key: list[str] = typer.Option(..., "--corpus-key", help="Corpus key. Repeatable."),
576
+ selection_kind: str = typer.Option("near_90s", "--selection-kind"),
577
+ observation_kind: str = typer.Option("demian_frame_label_v1", "--observation-kind"),
578
+ label_model_id: str = typer.Option("demian-v1", "--label-model-id"),
579
+ label_prompt_version: str = typer.Option(
580
+ "demian-frame-label-v1",
581
+ "--label-prompt-version",
582
+ ),
583
+ model_config_hash: str | None = typer.Option(None, "--model-config-hash"),
584
+ endpoint_url: str | None = typer.Option(None, "--endpoint-url"),
585
+ limit: int | None = typer.Option(None, "--limit", min=1, help="Cap selected frames."),
586
+ idempotency_key: str = typer.Option(..., "--idempotency-key", help="Manifest idempotency key."),
587
+ cost_cap_usd: float | None = typer.Option(None, "--cost-cap-usd", min=0.0),
588
+ max_parallel_requests: int = typer.Option(1, "--max-parallel-requests", min=1),
589
+ endpoint_timeout_sec: int = typer.Option(300, "--endpoint-timeout-sec", min=1),
590
+ write: bool = typer.Option(False, "--write", help="Create the processing manifest."),
591
+ format: Format = format_option(),
592
+ ) -> None:
593
+ """Queue missing-only DeMiAn labels for canonical sampled corpus frames."""
594
+ _require_internal_admin_for_write(ctx, write=write)
595
+ settings = _settings_for_command(ctx, write=write)
596
+
597
+ async def run() -> None:
598
+ from dal.processing import media_jobs
599
+
600
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
601
+ _db,
602
+ dal_ctx,
603
+ ):
604
+ selection_spec = {
605
+ "kind": "by_corpus",
606
+ "corpus_keys": corpus_key,
607
+ "selection_kind": selection_kind,
608
+ "observation_kind": observation_kind,
609
+ "label_model_id": label_model_id,
610
+ "label_prompt_version": label_prompt_version,
611
+ "model_config_hash": model_config_hash,
612
+ "endpoint_url": endpoint_url,
613
+ "limit": limit,
614
+ "only_missing_labels": True,
615
+ "max_parallel_requests_per_worker": max_parallel_requests,
616
+ "endpoint_timeout_sec": endpoint_timeout_sec,
617
+ }
618
+ sink_overrides = {
619
+ "observation": {
620
+ "table": "observations.frame_observations",
621
+ "observation_kind": observation_kind,
622
+ }
623
+ }
624
+ resource_overrides = {"cost_cap_usd": cost_cap_usd} if cost_cap_usd is not None else {}
625
+ if write:
626
+ manifest = await media_jobs.queue_processor_job(
627
+ dal_ctx,
628
+ processor_ref=media_jobs.DEMIAN_FRAME_LABEL_PROCESSOR_REF,
629
+ selection_spec=selection_spec,
630
+ sink_overrides=sink_overrides,
631
+ resource_overrides=resource_overrides,
632
+ submitted_by_principal=_SUBMITTED_BY,
633
+ idempotency_key=idempotency_key,
634
+ )
635
+ else:
636
+ manifest = {
637
+ "dry_run": True,
638
+ "processor_ref": media_jobs.DEMIAN_FRAME_LABEL_PROCESSOR_REF,
639
+ "selection_spec": selection_spec,
640
+ "sink_overrides": sink_overrides,
641
+ "resource_overrides": resource_overrides,
642
+ }
643
+ render(manifest, format=format)
644
+
645
+ asyncio.run(run())
@@ -77,7 +77,13 @@ def _resolve_buildspec(repo_root: Path | None) -> list[str]:
77
77
  )
78
78
 
79
79
 
80
- CONTEXT_SETTINGS = {"allow_extra_args": True, "ignore_unknown_options": True}
80
+ CONTEXT_SETTINGS = {
81
+ "allow_extra_args": True,
82
+ "ignore_unknown_options": True,
83
+ # Disable Click's eager --help so `spec <verb> --help` reaches the wrapped
84
+ # engine (or `cmd_pr`'s argparse) instead of the generic wrapper help.
85
+ "help_option_names": [],
86
+ }
81
87
 
82
88
 
83
89
  def run_buildspec(args: list[str]) -> int:
@@ -150,6 +156,124 @@ def doctor() -> int:
150
156
  return 0 if healthy else 1
151
157
 
152
158
 
159
+ def _print_active_changes(repo_root: Path) -> None:
160
+ """List active (non-archived) change ids to guide a missing/incorrect arg."""
161
+
162
+ changes_dir = repo_root / "buildspec" / "changes"
163
+ if not changes_dir.is_dir():
164
+ return
165
+ names = sorted(p.name for p in changes_dir.iterdir() if p.is_dir() and p.name != "archive")
166
+ if names:
167
+ info("Active changes:")
168
+ for name in names:
169
+ info(f" {name}")
170
+
171
+
172
+ def cmd_pr(args: list[str]) -> int:
173
+ """Render a reviewer-ready PR body from a BuildSpec change folder.
174
+
175
+ Prints the body (default), writes it with ``--body-file``, or opens the PR
176
+ with ``--create`` via ``gh pr create``. The change-id is a required argument:
177
+ branches do not encode it and many changes share a branch.
178
+ """
179
+
180
+ import argparse
181
+ import tempfile
182
+
183
+ from cli.commands.spec_pr import default_title, render_pr_body, resolve_change_dir
184
+
185
+ repo_root = _repo_root()
186
+ if repo_root is None:
187
+ error("Run `buildai spec pr` from the Build AI repo checkout.")
188
+ return 1
189
+
190
+ parser = argparse.ArgumentParser(
191
+ prog="buildai spec pr",
192
+ description="Render a pull-request body from a BuildSpec change.",
193
+ )
194
+ parser.add_argument("change_id", nargs="?", help="BuildSpec change id (required).")
195
+ parser.add_argument(
196
+ "--body-file", help="Write the rendered body to this path instead of stdout."
197
+ )
198
+ parser.add_argument("--create", action="store_true", help="Open the PR with `gh pr create`.")
199
+ parser.add_argument("--title", help="PR title; prefer a Conventional Commit subject.")
200
+ parser.add_argument("--base", default="main", help="Base branch for --create (default: main).")
201
+ parser.add_argument("--draft", action="store_true", help="Create the PR as a draft.")
202
+ try:
203
+ ns = parser.parse_args(args)
204
+ except SystemExit as exc: # argparse already printed help or a usage error
205
+ return int(exc.code or 0)
206
+
207
+ if not ns.change_id:
208
+ error(
209
+ "Usage: buildai spec pr <change-id> "
210
+ "[--body-file PATH | --create] [--title T] [--base B] [--draft]"
211
+ )
212
+ _print_active_changes(repo_root)
213
+ return 1
214
+
215
+ change_dir = resolve_change_dir(repo_root, ns.change_id)
216
+ if change_dir is None:
217
+ error(
218
+ f"No change folder for '{ns.change_id}' under buildspec/changes/ (active or archived)."
219
+ )
220
+ _print_active_changes(repo_root)
221
+ return 1
222
+
223
+ body = render_pr_body(change_dir, ns.change_id, repo_root)
224
+ title = ns.title or default_title(ns.change_id)
225
+
226
+ if ns.create:
227
+ if not shutil.which("gh"):
228
+ error(
229
+ "`gh` CLI not found. Install GitHub CLI, or use `--body-file` then `gh pr create`."
230
+ )
231
+ return 1
232
+ if not ns.title:
233
+ info(
234
+ f"No --title given; using derived '{title}'. Prefer a Conventional Commit subject."
235
+ )
236
+ with tempfile.NamedTemporaryFile(
237
+ "w", suffix=".md", delete=False, encoding="utf-8"
238
+ ) as handle:
239
+ handle.write(body)
240
+ body_path = handle.name
241
+ command = [
242
+ "gh",
243
+ "pr",
244
+ "create",
245
+ "--title",
246
+ title,
247
+ "--body-file",
248
+ body_path,
249
+ "--base",
250
+ ns.base,
251
+ ]
252
+ if ns.draft:
253
+ command.append("--draft")
254
+ try:
255
+ code = subprocess.run(command, cwd=repo_root).returncode
256
+ if code == 0:
257
+ info(
258
+ "PR opened. Fill in the Validation block (and any blank "
259
+ "PR-local sections) before requesting review."
260
+ )
261
+ return code
262
+ finally:
263
+ try:
264
+ os.unlink(body_path)
265
+ except OSError:
266
+ pass
267
+
268
+ if ns.body_file:
269
+ Path(ns.body_file).write_text(body, encoding="utf-8")
270
+ info(f"Wrote PR body to {ns.body_file}")
271
+ return 0
272
+
273
+ print(body)
274
+ return 0
275
+
276
+
153
277
  def spec_command(
154
278
  ctx: typer.Context,
155
279
  args: list[str] | None = typer.Argument(
@@ -162,6 +286,8 @@ def spec_command(
162
286
  forwarded = list(args or []) + list(ctx.args)
163
287
  if forwarded and forwarded[0] == "doctor":
164
288
  raise typer.Exit(code=doctor())
289
+ if forwarded and forwarded[0] == "pr":
290
+ raise typer.Exit(code=cmd_pr(forwarded[1:]))
165
291
  if not forwarded:
166
292
  forwarded = ["--help"]
167
293
  try:
@@ -0,0 +1,303 @@
1
+ """Render a reviewer-ready pull-request body from a BuildSpec change folder.
2
+
3
+ `uv run buildai spec pr <change-id>` projects a finished change into a PR body
4
+ instead of asking the author to re-type what `proposal.md`, `design.md`, and
5
+ `specs/**/spec.md` already hold. The rendered body is a *view* of the change,
6
+ never a copy, so the change folder stays the single source of truth.
7
+
8
+ This module is pure (no console or process IO) so it can be unit-tested. The CLI
9
+ glue — argument parsing, `--body-file`, and `gh pr create` — lives in
10
+ `cli.commands.spec`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from pathlib import Path
17
+
18
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")
19
+ _COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
20
+ _TASK_DONE_RE = re.compile(r"^\s*-\s*\[x\]", re.MULTILINE | re.IGNORECASE)
21
+ _TASK_ANY_RE = re.compile(r"^\s*-\s*\[[ xX]\]", re.MULTILINE)
22
+ _FENCE_RE = re.compile(r"^\s*(```|~~~)")
23
+ _ARCHIVE_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-")
24
+
25
+ _HEADER = (
26
+ "<!-- Rendered by `uv run buildai spec pr {change_id}`. "
27
+ "It is a view of the BuildSpec change, not the diff — reconcile "
28
+ "'What changed' against the actual diff before submitting. -->"
29
+ )
30
+
31
+ _VALIDATION_SKELETON = (
32
+ "- [ ] Local checks run:\n"
33
+ "- [ ] Relevant tests run:\n"
34
+ "- [ ] UI/screenshots attached, if applicable:\n"
35
+ "- [ ] Generated artifacts updated, if applicable:"
36
+ )
37
+
38
+ _DATA_SKELETON = (
39
+ "Fill this out for any change that publishes, repairs, consumes, or trains "
40
+ "on data. Write `N/A` only when the PR has no data/artifact/training "
41
+ "impact.\n\n"
42
+ "- Dataset / artifact contract:\n"
43
+ "- External artifact identifiers:\n"
44
+ " - GCS URI + generation, Modal run id, DB query, checksum, or equivalent:\n"
45
+ "- Producer commit:\n"
46
+ "- Consumer / loader commit:\n"
47
+ "- Validation report or command output:\n"
48
+ "- Downstream owner/action:"
49
+ )
50
+
51
+
52
+ def _split_sections(text: str) -> dict[str, str]:
53
+ """Map each level-2 (`## `) heading, lowercased, to its body text.
54
+
55
+ Deeper headings (`### `, `#### `) stay inside the enclosing section body so
56
+ requirement/scenario structure in spec deltas is preserved.
57
+ """
58
+
59
+ sections: dict[str, str] = {}
60
+ current: str | None = None
61
+ buf: list[str] = []
62
+ in_fence = False
63
+ for line in text.splitlines():
64
+ # Only track fences inside a section. Preamble (before the first `## `)
65
+ # is never rendered, so an unbalanced preamble fence cannot leak its
66
+ # state into — and swallow — the first real section.
67
+ if current is not None and _FENCE_RE.match(line):
68
+ in_fence = not in_fence
69
+ buf.append(line)
70
+ continue
71
+ match = _HEADING_RE.match(line)
72
+ if not in_fence and match and len(match.group(1)) == 2:
73
+ if current is not None:
74
+ sections[current] = "\n".join(buf).strip()
75
+ current = match.group(2).strip().lower()
76
+ buf = []
77
+ elif current is not None:
78
+ buf.append(line)
79
+ if current is not None:
80
+ sections[current] = "\n".join(buf).strip()
81
+ return sections
82
+
83
+
84
+ def _strip_placeholders(body: str) -> str:
85
+ """Remove HTML comments and bare template bullets so empties read as empty."""
86
+
87
+ body = _COMMENT_RE.sub("", body)
88
+ kept: list[str] = []
89
+ for line in body.splitlines():
90
+ stripped = line.strip()
91
+ if not stripped or stripped in {"-", "*", ">"}:
92
+ continue
93
+ kept.append(line.rstrip())
94
+ return "\n".join(kept).strip()
95
+
96
+
97
+ def _is_empty(body: str) -> bool:
98
+ """True when a section carries no substantive content (only comments/blanks)."""
99
+
100
+ return not _strip_placeholders(body)
101
+
102
+
103
+ def _section(sections: dict[str, str], *names: str) -> str:
104
+ """Return the first non-empty section body matching any alias name."""
105
+
106
+ for name in names:
107
+ body = sections.get(name.lower())
108
+ if body and not _is_empty(body):
109
+ return body.strip()
110
+ return ""
111
+
112
+
113
+ def _labeled(label: str, body: str) -> str:
114
+ return f"**{label}**\n\n{body.strip()}" if body.strip() else ""
115
+
116
+
117
+ def _join_parts(parts: list[str]) -> str:
118
+ return "\n\n".join(part.strip() for part in parts if part and part.strip())
119
+
120
+
121
+ def _demote(text: str, by: int = 2) -> str:
122
+ """Push every Markdown heading down `by` levels (capped at 6) so embedded
123
+ spec headings nest under the PR's own `##` sections instead of competing."""
124
+
125
+ out: list[str] = []
126
+ in_fence = False
127
+ for line in text.splitlines():
128
+ if _FENCE_RE.match(line):
129
+ in_fence = not in_fence
130
+ out.append(line)
131
+ continue
132
+ match = re.match(r"^(#{1,6})(\s.*)$", line)
133
+ if not in_fence and match:
134
+ level = min(6, len(match.group(1)) + by)
135
+ out.append("#" * level + match.group(2))
136
+ else:
137
+ out.append(line)
138
+ return "\n".join(out)
139
+
140
+
141
+ def _read(path: Path) -> str:
142
+ try:
143
+ return path.read_text(encoding="utf-8")
144
+ except (FileNotFoundError, OSError):
145
+ return ""
146
+
147
+
148
+ def count_tasks(text: str) -> tuple[int, int]:
149
+ """Return (checked, total) task checkboxes in a tasks.md body."""
150
+
151
+ return len(_TASK_DONE_RE.findall(text)), len(_TASK_ANY_RE.findall(text))
152
+
153
+
154
+ def _render_spec_deltas(change_dir: Path) -> str:
155
+ """Render every `specs/**/spec.md` delta, headings demoted, capability-labeled."""
156
+
157
+ specs_root = change_dir / "specs"
158
+ if not specs_root.is_dir():
159
+ return ""
160
+ parts: list[str] = []
161
+ for spec in sorted(specs_root.rglob("spec.md")):
162
+ content = _read(spec).strip()
163
+ if not content:
164
+ continue
165
+ capability = spec.parent.name
166
+ parts.append(f"**Capability: `{capability}`**\n\n{_demote(content)}")
167
+ return _join_parts(parts)
168
+
169
+
170
+ def resolve_change_dir(repo_root: Path, change_id: str) -> Path | None:
171
+ """Locate a change folder by id, whether active or archived.
172
+
173
+ Active changes live at `buildspec/changes/<id>/`; archived ones are renamed
174
+ to `buildspec/changes/archive/<date>-<id>/`, so the archive is matched on the
175
+ `-<id>` suffix (newest wins).
176
+ """
177
+
178
+ active = repo_root / "buildspec" / "changes" / change_id
179
+ if active.is_dir():
180
+ return active
181
+ archive = repo_root / "buildspec" / "changes" / "archive"
182
+ if archive.is_dir():
183
+ # Archived dirs are `YYYY-MM-DD-<change-id>`. Strip the date prefix and
184
+ # match the id EXACTLY — a plain suffix test would let `setup` resolve
185
+ # `2026-06-01-database-setup`. Newest (last-sorted) wins on a true tie.
186
+ matches = sorted(
187
+ path
188
+ for path in archive.iterdir()
189
+ if path.is_dir() and _ARCHIVE_DATE_RE.sub("", path.name) == change_id
190
+ )
191
+ if matches:
192
+ return matches[-1]
193
+ return None
194
+
195
+
196
+ def _find_repo_root(change_dir: Path) -> Path:
197
+ """Repo root for a change dir — active or archived — found as the nearest
198
+ ancestor that contains a `buildspec/` directory."""
199
+
200
+ for parent in change_dir.parents:
201
+ if (parent / "buildspec").is_dir():
202
+ return parent
203
+ return change_dir.parents[-1]
204
+
205
+
206
+ def _change_path_label(repo_root: Path, change_dir: Path) -> str:
207
+ try:
208
+ return str(change_dir.relative_to(repo_root))
209
+ except ValueError:
210
+ return str(change_dir)
211
+
212
+
213
+ def default_title(change_id: str) -> str:
214
+ """Humanize a change-id into a fallback PR title (author should set the real one)."""
215
+
216
+ words = change_id.replace("-", " ").replace("_", " ").strip()
217
+ return words[:1].upper() + words[1:] if words else change_id
218
+
219
+
220
+ def render_pr_body(change_dir: Path, change_id: str, repo_root: Path | None = None) -> str:
221
+ """Assemble a PR-body markdown projection of a change folder.
222
+
223
+ Auto sections (why/what/approach/trade-offs/contracts/scope/rollback/caveats)
224
+ render from proposal/design/spec content and are omitted when their source is
225
+ empty. The Linked-BuildSpec, Validation, and Data-handoff blocks always render
226
+ because they are PR-local and human-filled.
227
+ """
228
+
229
+ repo_root = repo_root or _find_repo_root(change_dir)
230
+ proposal = _split_sections(_read(change_dir / "proposal.md"))
231
+ design = _split_sections(_read(change_dir / "design.md"))
232
+ done, total = count_tasks(_read(change_dir / "tasks.md"))
233
+
234
+ linked = [f"- Change: `{_change_path_label(repo_root, change_dir)}`"]
235
+ if total:
236
+ linked.append(f"- Implementation: {done}/{total} tasks checked in the change ledger")
237
+ linked.append("- Closes: <!-- #issue or N/A -->")
238
+ linked.append("")
239
+ linked.append(f"BuildSpec: {change_id}")
240
+
241
+ # (title, content, always_render)
242
+ blocks: list[tuple[str, str, bool]] = [
243
+ ("Linked BuildSpec & issues", "\n".join(linked), True),
244
+ (
245
+ "Why / context",
246
+ _join_parts([_section(proposal, "Why"), _section(design, "Context")]),
247
+ False,
248
+ ),
249
+ ("What changed", _section(proposal, "What Changes", "What Changed"), False),
250
+ ("Approach & alternatives considered", _section(design, "Decisions"), False),
251
+ (
252
+ "Trade-offs & risks",
253
+ _section(design, "Risks / Trade-offs", "Risks/Trade-offs", "Risks", "Trade-offs"),
254
+ False,
255
+ ),
256
+ ("Contract deltas & how to verify", _render_spec_deltas(change_dir), False),
257
+ (
258
+ "Scope & non-goals",
259
+ _join_parts(
260
+ [
261
+ _labeled("Impact", _section(proposal, "Impact")),
262
+ _section(design, "Goals / Non-Goals", "Goals/Non-Goals"),
263
+ _labeled("Out of scope", _section(proposal, "Out of Scope")),
264
+ ]
265
+ ),
266
+ False,
267
+ ),
268
+ (
269
+ "Rollout & rollback",
270
+ _join_parts(
271
+ [
272
+ _section(
273
+ design,
274
+ "Migration Plan",
275
+ "Proposed Migration Shape",
276
+ "Migration",
277
+ "Rollout Plan",
278
+ ),
279
+ _labeled("Rollback", _section(design, "Rollback", "Rollback Strategy")),
280
+ ]
281
+ ),
282
+ False,
283
+ ),
284
+ ("Validation", _VALIDATION_SKELETON, True),
285
+ ("Data, artifact, or training handoff", _DATA_SKELETON, True),
286
+ (
287
+ "Caveats, follow-up & open questions",
288
+ _join_parts(
289
+ [
290
+ _section(design, "Open Questions"),
291
+ _labeled("Discussion capture", _section(proposal, "Discussion Capture")),
292
+ ]
293
+ ),
294
+ False,
295
+ ),
296
+ ]
297
+
298
+ rendered = [_HEADER.format(change_id=change_id)]
299
+ for title, content, always in blocks:
300
+ if not always and _is_empty(content):
301
+ continue
302
+ rendered.append(f"## {title}\n\n{content.strip()}")
303
+ return "\n\n".join(rendered).strip() + "\n"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "buildai-cli"
7
- version = "0.3.89"
7
+ version = "0.3.91"
8
8
  description = "Build AI CLI (Typer)"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes