@surlymochan/evalid 0.0.0-stage → 0.3.1

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 (192) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +315 -2
  4. package/RELEASE_NOTES.md +102 -0
  5. package/atoms/registry.json +758 -0
  6. package/bin/xflow.js +7208 -0
  7. package/docs/adoption/README.md +128 -0
  8. package/docs/adoption/openflow-release-hardening.md +61 -0
  9. package/docs/benchmark-openflow-vs-gstack-superpowers-openspec-2026-05-25.md +81 -0
  10. package/docs/compatibility.md +52 -0
  11. package/docs/competitive-benchmark.md +108 -0
  12. package/docs/corps-operator-guide.md +75 -0
  13. package/docs/demo-proof.md +126 -0
  14. package/docs/examples-gallery.md +115 -0
  15. package/docs/fixtures/tracker-item.json +7 -0
  16. package/docs/goal-vs-codex.md +129 -0
  17. package/docs/gstack-comparison.md +88 -0
  18. package/docs/install-upgrade.md +133 -0
  19. package/docs/integrations.md +49 -0
  20. package/docs/launch-demo.md +123 -0
  21. package/docs/launch-dossier.md +180 -0
  22. package/docs/methodology.md +149 -0
  23. package/docs/npm-publish-handoff.md +83 -0
  24. package/docs/openspec-migration.md +106 -0
  25. package/docs/public-benchmark.md +68 -0
  26. package/docs/public-release.md +145 -0
  27. package/docs/quality-assessment.md +139 -0
  28. package/docs/quickstart.md +156 -0
  29. package/docs/reviewer-guide.md +43 -0
  30. package/docs/spec-kit-benchmark.md +73 -0
  31. package/docs/super-assistant-comparison.md +106 -0
  32. package/docs/superpowers-comparison.md +82 -0
  33. package/docs/team-adoption.md +123 -0
  34. package/docs/tooling-matrix.md +91 -0
  35. package/docs/walkthrough.md +76 -0
  36. package/docs/workflow-manual.md +1125 -0
  37. package/package.json +83 -4
  38. package/prompts/pencil-draft.md +46 -0
  39. package/prompts/pencil-refine.md +46 -0
  40. package/prompts/phase-artifact.md +24 -0
  41. package/prompts/phase-execution.md +32 -0
  42. package/schemas/competitor-reconstruction-contract.schema.json +91 -0
  43. package/schemas/llm-design-review-aggregate.schema.json +83 -0
  44. package/schemas/visual-benchmark.schema.json +978 -0
  45. package/schemas/workflow.schema.json +100 -0
  46. package/scripts/run-tests.mjs +23 -0
  47. package/scripts/takein_digest.py +139 -0
  48. package/specs/README.md +118 -0
  49. package/specs/architecture.md +36 -0
  50. package/specs/product.md +33 -0
  51. package/specs/workflow.md +349 -0
  52. package/src/agent_team/atoms/codex-app-bridge.js +8 -0
  53. package/src/agent_team/atoms/design/competitor-reconstruction-review.js +44 -0
  54. package/src/agent_team/atoms/design/contract-freeze.js +8 -0
  55. package/src/agent_team/atoms/design/generation-contract.js +442 -0
  56. package/src/agent_team/atoms/design/reconstruction-pack.js +246 -0
  57. package/src/agent_team/atoms/design/reference-surface-lock.js +49 -0
  58. package/src/agent_team/atoms/patch-challenge.js +19 -0
  59. package/src/agent_team/atoms/pencil/accept.js +9 -0
  60. package/src/agent_team/atoms/pencil/draft.js +365 -0
  61. package/src/agent_team/atoms/pencil/refine.js +229 -0
  62. package/src/agent_team/atoms/phase-run-dispatch.js +8 -0
  63. package/src/agent_team/atoms/team-run.js +380 -0
  64. package/src/agent_team/atoms/visual/aesthetic-review.js +231 -0
  65. package/src/agent_team/atoms/visual/benchmark-repair-plan.js +291 -0
  66. package/src/agent_team/atoms/visual/benchmark.js +1652 -0
  67. package/src/agent_team/atoms/visual/design-selection.js +23 -0
  68. package/src/agent_team/atoms/visual/direction-synthesis.js +124 -0
  69. package/src/agent_team/atoms/visual/dom-rect-export.js +129 -0
  70. package/src/agent_team/atoms/visual/image-diff.js +186 -0
  71. package/src/agent_team/atoms/visual/layout-competition.js +8 -0
  72. package/src/agent_team/atoms/visual/page-evidence-capture.js +806 -0
  73. package/src/agent_team/atoms/visual/playwright-runtime.js +8 -0
  74. package/src/agent_team/atoms/visual/review-aggregate.js +87 -0
  75. package/src/agent_team/atoms/visual/review.js +77 -0
  76. package/src/agent_team/atoms/visual/visual-report.js +197 -0
  77. package/src/agent_team/atoms/visual/visual-token-export.js +417 -0
  78. package/src/agent_team/common.js +311 -0
  79. package/src/core/atoms/agent-invoke.js +684 -0
  80. package/src/core/atoms/event/ingest.js +30 -0
  81. package/src/core/atoms/experience/digest.js +55 -0
  82. package/src/core/atoms/gate/artifact-verify.js +83 -0
  83. package/src/core/atoms/gate/competitor-contract-validate.js +119 -0
  84. package/src/core/atoms/gate/evidence-collect.js +64 -0
  85. package/src/core/atoms/gate/payload-from-phase-run.js +78 -0
  86. package/src/core/atoms/gate/visual-benchmark-validate.js +183 -0
  87. package/src/core/atoms/gate/workbench.js +79 -0
  88. package/src/core/atoms/intake/classify.js +15 -0
  89. package/src/core/atoms/intake/design-skill-select.js +16 -0
  90. package/src/core/atoms/intake/preview-or-refine.js +19 -0
  91. package/src/core/atoms/intake/recommend.js +20 -0
  92. package/src/core/atoms/intake/research-same-category.js +19 -0
  93. package/src/core/atoms/intervention/create.js +30 -0
  94. package/src/core/atoms/memory/manage.js +27 -0
  95. package/src/core/atoms/memory/mount.js +13 -0
  96. package/src/core/atoms/mission/create.js +19 -0
  97. package/src/core/atoms/mission/list.js +14 -0
  98. package/src/core/atoms/mission/show.js +26 -0
  99. package/src/core/atoms/notify/checkpoint.js +46 -0
  100. package/src/core/atoms/phase-run/complete.js +42 -0
  101. package/src/core/atoms/phase-run/fail.js +40 -0
  102. package/src/core/atoms/phase-run/persist.js +39 -0
  103. package/src/core/atoms/phase-run/start.js +30 -0
  104. package/src/core/atoms/plugin/run.js +76 -0
  105. package/src/core/atoms/policy/gate.js +90 -0
  106. package/src/core/atoms/policy/resolve.js +11 -0
  107. package/src/core/atoms/storage/sync.js +57 -0
  108. package/src/core/atoms/workflow/advance.js +25 -0
  109. package/src/core/atoms/workflow/guard.js +127 -0
  110. package/src/core/atoms/workflow/state-load.js +37 -0
  111. package/src/core/bindings.js +37 -0
  112. package/src/core/change-artifacts.js +134 -0
  113. package/src/core/competitive-score.js +265 -0
  114. package/src/core/concurrency-policy.js +35 -0
  115. package/src/core/execution-log.js +108 -0
  116. package/src/core/intake-recommendation.js +134 -0
  117. package/src/core/memory-state.js +62 -0
  118. package/src/core/mission-state.js +780 -0
  119. package/src/core/pending-gates.js +66 -0
  120. package/src/core/phase-cost.js +116 -0
  121. package/src/core/phase-run-read.js +27 -0
  122. package/src/core/policy-overlay.js +180 -0
  123. package/src/core/state-store.js +201 -0
  124. package/src/core/workflow-executor.js +998 -0
  125. package/src/core/workflow-integrity.js +111 -0
  126. package/src/core/workflow-loader.js +163 -0
  127. package/src/core/workflow-rerun.js +84 -0
  128. package/src/core/workflow-state.js +119 -0
  129. package/src/server.js +1307 -0
  130. package/workflows/corps.yaml +479 -0
  131. package/workflows/yolo.yaml +233 -0
  132. package/xflow/README.md +76 -0
  133. package/xflow/aha/SKILL.md +82 -0
  134. package/xflow/aha/scripts/append_aha.py +50 -0
  135. package/xflow/archive/scripts/archive_change.py +338 -0
  136. package/xflow/archive/templates/merge-design.md +54 -0
  137. package/xflow/archive/templates/merge-workflow.md +26 -0
  138. package/xflow/atoms/__init__.py +6 -0
  139. package/xflow/atoms/a1_issue_create_or_patch.py +70 -0
  140. package/xflow/atoms/a2_branch_create_and_link.py +42 -0
  141. package/xflow/atoms/a3_repo_doctor.py +11 -0
  142. package/xflow/atoms/a4_project_set_status.py +11 -0
  143. package/xflow/atoms/a5_archive_commit_push_close.py +11 -0
  144. package/xflow/atoms/a6_pr_create.py +72 -0
  145. package/xflow/atoms/b1_change_scaffold.py +11 -0
  146. package/xflow/atoms/b2_status_read.py +16 -0
  147. package/xflow/atoms/b2b_status_write.py +20 -0
  148. package/xflow/atoms/b3_status_transition.py +27 -0
  149. package/xflow/atoms/b4_handoff_scaffold_or_refresh.py +11 -0
  150. package/xflow/atoms/b5_aha_append.py +11 -0
  151. package/xflow/atoms/b6_takein_digest.py +11 -0
  152. package/xflow/atoms/b6b_lookback_digest.py +11 -0
  153. package/xflow/atoms/b7_change_mission_bind.py +43 -0
  154. package/xflow/atoms/c1_mem.py +54 -0
  155. package/xflow/atoms/e6_gate_local_precheck.py +23 -0
  156. package/xflow/atoms/h1_design_lite_gate.py +74 -0
  157. package/xflow/atoms/i6a_tdd_run.py +104 -0
  158. package/xflow/atoms/i6b_tdd_proof_validate.py +165 -0
  159. package/xflow/atoms/i6c_tdd_quality_review.py +357 -0
  160. package/xflow/atoms/j1_tests_run.py +129 -0
  161. package/xflow/atoms/j4a_spec_consistency_structural.py +105 -0
  162. package/xflow/atoms/j4b_spec_delta_review.py +316 -0
  163. package/xflow/atoms/j4c_openspec_migration_map.py +121 -0
  164. package/xflow/atoms/j4d_spec_clarify_gate.py +373 -0
  165. package/xflow/atoms/j6_cr_review.py +443 -0
  166. package/xflow/atoms/k1_artifacts_complete_check.py +39 -0
  167. package/xflow/atoms/k2_merge_snippets_apply.py +11 -0
  168. package/xflow/atoms/k3_handoff_refresh.py +14 -0
  169. package/xflow/atoms/k4_mem_lesson_persist.py +65 -0
  170. package/xflow/atoms/k5_archive_publish.py +21 -0
  171. package/xflow/atoms/k6_aha_merge.py +42 -0
  172. package/xflow/atoms/tdd_lib.py +747 -0
  173. package/xflow/corps/SKILL.md +109 -0
  174. package/xflow/exec/scripts/set_issue_in_progress.py +159 -0
  175. package/xflow/goal/SKILL.md +150 -0
  176. package/xflow/handoff/SKILL.md +95 -0
  177. package/xflow/handoff/scripts/scaffold_handoff.py +304 -0
  178. package/xflow/lookback/SKILL.md +66 -0
  179. package/xflow/lookback/scripts/lookback_digest.py +46 -0
  180. package/xflow/openissue/scripts/open_issue_flow.py +438 -0
  181. package/xflow/plan/SKILL.md +38 -0
  182. package/xflow/ralph/SKILL.md +123 -0
  183. package/xflow/scripts/check_installed_xflow_skill_sync.sh +44 -0
  184. package/xflow/scripts/common.py +480 -0
  185. package/xflow/scripts/doctor.py +278 -0
  186. package/xflow/scripts/sync_installed_xflow_skill.sh +134 -0
  187. package/xflow/takein/SKILL.md +81 -0
  188. package/xflow/takein/scripts/takein_digest.py +88 -0
  189. package/xflow/yolo/SKILL.md +66 -0
  190. package/xflow/yolo/scripts/scaffold_specs_change.py +673 -0
  191. package/xflow/yolo/scripts/yolo_gate.py +312 -0
  192. package/xflow/yolo/templates/DESIGN.md +179 -0
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import re
5
+ import subprocess
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ PROJECT_SEARCH_ROOTS = [
12
+ Path.home() / "workspace/project/private",
13
+ Path.home() / "workspace/project/public",
14
+ Path.home() / "workspace/project/third-party",
15
+ Path.home() / "Documents/workspace/project/private",
16
+ Path.home() / "Documents/workspace/project/public",
17
+ Path.home() / "Documents/workspace/project/third-party",
18
+ ]
19
+
20
+
21
+ ALLOWED_STAGE_TRANSITIONS: dict[str, set[str]] = {
22
+ "change-init": {"change-init", "design-check", "brainstorm", "proposal-freeze", "proposal-consistency-check", "plan"},
23
+ "design-check": {"design-check", "brainstorm", "proposal-freeze", "proposal-consistency-check", "plan"},
24
+ "brainstorm": {"brainstorm", "proposal-freeze", "proposal-consistency-check", "plan"},
25
+ "proposal-freeze": {"proposal-freeze", "proposal-consistency-check", "plan"},
26
+ "proposal-consistency-check": {"proposal-consistency-check", "plan"},
27
+ "plan": {"plan", "tdd"},
28
+ "openissue": {"openissue", "tdd"},
29
+ "tdd": {"tdd", "execute", "verify"},
30
+ "execute": {"execute", "tdd", "verify"},
31
+ "verify": {"verify", "tdd", "execute", "archive"},
32
+ "archive": {"archive"},
33
+ }
34
+
35
+ ALLOWED_STATUS_TRANSITIONS: dict[str, set[str]] = {
36
+ "draft": {"draft", "active", "blocked"},
37
+ "active": {"active", "done", "blocked"},
38
+ "blocked": {"blocked", "active", "done"},
39
+ "done": {"done"},
40
+ }
41
+
42
+
43
+ def now_iso() -> str:
44
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
45
+
46
+
47
+ def slugify(value: str) -> str:
48
+ value = value.strip().lower()
49
+ value = re.sub(r"[^a-z0-9]+", "-", value)
50
+ value = re.sub(r"-{2,}", "-", value)
51
+ return value.strip("-") or "change"
52
+
53
+
54
+ def run(cmd: list[str], *, cwd: str | Path | None = None, capture_output: bool = True) -> subprocess.CompletedProcess[str]:
55
+ return subprocess.run(
56
+ cmd,
57
+ cwd=str(cwd) if cwd else None,
58
+ check=True,
59
+ text=True,
60
+ capture_output=capture_output,
61
+ )
62
+
63
+
64
+ def run_stdout(cmd: list[str], *, cwd: str | Path | None = None) -> str:
65
+ return run(cmd, cwd=cwd).stdout.strip()
66
+
67
+
68
+ def gh_json(args: list[str], *, cwd: str | Path | None = None) -> Any:
69
+ output = run_stdout(["gh", *args], cwd=cwd)
70
+ return json.loads(output)
71
+
72
+
73
+ def gh_graphql(query: str, variables: dict[str, Any] | None = None) -> Any:
74
+ cmd = ["gh", "api", "graphql", "-f", f"query={query}"]
75
+ for key, value in (variables or {}).items():
76
+ if isinstance(value, bool):
77
+ cmd.extend(["-F", f"{key}={'true' if value else 'false'}"])
78
+ else:
79
+ cmd.extend(["-F", f"{key}={value}"])
80
+ output = run_stdout(cmd)
81
+ return json.loads(output)
82
+
83
+
84
+ def read_text(path: Path) -> str:
85
+ return path.read_text(encoding="utf-8")
86
+
87
+
88
+ def write_text(path: Path, content: str) -> None:
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ path.write_text(content, encoding="utf-8")
91
+
92
+
93
+ def read_json(path: Path) -> dict[str, Any]:
94
+ return json.loads(read_text(path))
95
+
96
+
97
+ def write_json(path: Path, data: dict[str, Any]) -> None:
98
+ write_text(path, json.dumps(data, indent=2) + "\n")
99
+
100
+
101
+ def _is_allowed_transition(mapping: dict[str, set[str]], current_value: str | None, target_value: str) -> bool:
102
+ if not current_value:
103
+ return False
104
+ allowed = mapping.get(current_value)
105
+ if not allowed:
106
+ return False
107
+ return target_value in allowed
108
+
109
+
110
+ def is_allowed_stage_transition(current_stage: str | None, target_stage: str) -> bool:
111
+ return _is_allowed_transition(ALLOWED_STAGE_TRANSITIONS, current_stage, target_stage)
112
+
113
+
114
+ def is_allowed_status_transition(current_status: str | None, target_status: str) -> bool:
115
+ return _is_allowed_transition(ALLOWED_STATUS_TRANSITIONS, current_status, target_status)
116
+
117
+
118
+ def assert_stage_transition_allowed(
119
+ current_stage: str | None,
120
+ target_stage: str,
121
+ *,
122
+ current_status: str | None = None,
123
+ allow_terminal_status_override: tuple[str, ...] = (),
124
+ ) -> None:
125
+ if current_status in allow_terminal_status_override:
126
+ return
127
+ if not is_allowed_stage_transition(current_stage, target_stage):
128
+ raise SystemExit(
129
+ f"Illegal Superflow stage transition: {current_stage!r} -> {target_stage!r}. "
130
+ "Update status.json through the canonical workflow order instead of skipping phases."
131
+ )
132
+
133
+
134
+ def assert_status_transition_allowed(
135
+ current_status: str | None,
136
+ target_status: str,
137
+ *,
138
+ allow_terminal_status_override: tuple[str, ...] = (),
139
+ ) -> None:
140
+ if current_status in allow_terminal_status_override:
141
+ return
142
+ if not is_allowed_status_transition(current_status, target_status):
143
+ raise SystemExit(
144
+ f"Illegal Superflow status transition: {current_status!r} -> {target_status!r}. "
145
+ "Use the canonical workflow state progression instead of forcing status.json."
146
+ )
147
+
148
+
149
+ def record_stage_transition(status: dict[str, Any], target_stage: str, *, reason: str) -> None:
150
+ current_stage = status.get("current_stage")
151
+ history = status.setdefault("stage_history", [])
152
+ if not isinstance(history, list):
153
+ history = []
154
+ status["stage_history"] = history
155
+ if current_stage != target_stage:
156
+ history.append(
157
+ {
158
+ "from": current_stage,
159
+ "to": target_stage,
160
+ "reason": reason,
161
+ "at": now_iso(),
162
+ }
163
+ )
164
+ status["current_stage"] = target_stage
165
+
166
+
167
+ def record_value_transition(status: dict[str, Any], field: str, target_value: str, *, reason: str) -> None:
168
+ current_value = status.get(field)
169
+ history_field = f"{field}_history"
170
+ history = status.setdefault(history_field, [])
171
+ if not isinstance(history, list):
172
+ history = []
173
+ status[history_field] = history
174
+ if current_value != target_value:
175
+ history.append(
176
+ {
177
+ "from": current_value,
178
+ "to": target_value,
179
+ "reason": reason,
180
+ "at": now_iso(),
181
+ }
182
+ )
183
+ status[field] = target_value
184
+
185
+
186
+ def find_git_root(start: str | Path) -> Path:
187
+ root = run_stdout(["git", "rev-parse", "--show-toplevel"], cwd=start)
188
+ return Path(root)
189
+
190
+
191
+ def get_remote_url(checkout_path: str | Path, remote: str = "origin") -> str:
192
+ return run_stdout(["git", "remote", "get-url", remote], cwd=checkout_path)
193
+
194
+
195
+ def get_current_branch(checkout_path: str | Path) -> str:
196
+ return run_stdout(["git", "branch", "--show-current"], cwd=checkout_path)
197
+
198
+
199
+ def remote_matches_repo(remote_url: str, repo: str) -> bool:
200
+ normalized = remote_url.lower()
201
+ owner_repo = repo.lower()
202
+ return normalized.endswith(f"/{owner_repo}.git") or normalized.endswith(f":{owner_repo}.git") or normalized.endswith(f"/{owner_repo}") or normalized.endswith(f":{owner_repo}")
203
+
204
+
205
+ def ensure_checkout_matches_repo(checkout_path: str | Path, repo: str, remote: str = "origin") -> str:
206
+ remote_url = get_remote_url(checkout_path, remote)
207
+ if not remote_matches_repo(remote_url, repo):
208
+ raise SystemExit(
209
+ f"Checkout remote mismatch: {checkout_path} points {remote} to {remote_url!r}, expected repository {repo!r}."
210
+ )
211
+ return remote_url
212
+
213
+
214
+ def resolve_checkout_path(repo: str, *, preferred: str | Path | None = None, cwd_hint: str | Path | None = None) -> Path:
215
+ candidates: list[Path] = []
216
+
217
+ if preferred:
218
+ preferred_path = Path(preferred).expanduser().resolve()
219
+ if not preferred_path.exists():
220
+ raise SystemExit(f"Checkout path does not exist: {preferred_path}")
221
+ candidates.append(preferred_path)
222
+
223
+ if cwd_hint:
224
+ try:
225
+ git_root = find_git_root(cwd_hint)
226
+ candidates.append(git_root)
227
+ except subprocess.CalledProcessError:
228
+ pass
229
+
230
+ repo_name = repo.split("/", 1)[1]
231
+ for root in PROJECT_SEARCH_ROOTS:
232
+ candidate = root / repo_name
233
+ if candidate.exists():
234
+ candidates.append(candidate.resolve())
235
+
236
+ unique_candidates: list[Path] = []
237
+ seen: set[str] = set()
238
+ for candidate in candidates:
239
+ key = str(candidate)
240
+ if key in seen:
241
+ continue
242
+ seen.add(key)
243
+ unique_candidates.append(candidate)
244
+
245
+ matches: list[Path] = []
246
+ for candidate in unique_candidates:
247
+ try:
248
+ remote_url = get_remote_url(candidate)
249
+ except subprocess.CalledProcessError:
250
+ continue
251
+ if remote_matches_repo(remote_url, repo):
252
+ matches.append(candidate)
253
+
254
+ if not matches:
255
+ raise SystemExit(
256
+ f"Unable to resolve a local checkout for {repo}. Pass --checkout-path explicitly or create a checkout under a standard project root."
257
+ )
258
+ if len(matches) > 1:
259
+ joined = "\n".join(f"- {path}" for path in matches)
260
+ raise SystemExit(f"Multiple local checkouts match {repo}. Pass --checkout-path explicitly:\n{joined}")
261
+ return matches[0]
262
+
263
+
264
+ def ensure_branch_checked_out(checkout_path: str | Path, branch_name: str) -> None:
265
+ current = run_stdout(["git", "branch", "--show-current"], cwd=checkout_path)
266
+ if current == branch_name:
267
+ return
268
+ if local_branch_exists(checkout_path, branch_name):
269
+ run(["git", "checkout", branch_name], cwd=checkout_path)
270
+ return
271
+ if remote_branch_exists(checkout_path, branch_name):
272
+ run(["git", "checkout", "-B", branch_name, "--track", f"origin/{branch_name}"], cwd=checkout_path)
273
+ return
274
+ raise SystemExit(f"Branch not found locally or on origin: {branch_name}")
275
+
276
+
277
+ def local_branch_exists(checkout_path: str | Path, branch_name: str) -> bool:
278
+ result = subprocess.run(
279
+ ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"],
280
+ cwd=str(checkout_path),
281
+ text=True,
282
+ capture_output=True,
283
+ )
284
+ return result.returncode == 0
285
+
286
+
287
+ def remote_branch_exists(checkout_path: str | Path, branch_name: str) -> bool:
288
+ result = subprocess.run(
289
+ ["git", "ls-remote", "--exit-code", "--heads", "origin", branch_name],
290
+ cwd=str(checkout_path),
291
+ text=True,
292
+ capture_output=True,
293
+ )
294
+ return result.returncode == 0
295
+
296
+
297
+ def get_default_branch(repo: str) -> str:
298
+ data = gh_json(["repo", "view", repo, "--json", "defaultBranchRef"])
299
+ default_branch = data.get("defaultBranchRef", {}).get("name")
300
+ if not default_branch:
301
+ raise SystemExit(f"Could not determine default branch for {repo}")
302
+ return default_branch
303
+
304
+
305
+ def upsert_issue_comment(repo: str, issue_number: int, marker: str, body: str) -> int:
306
+ comments = gh_json(["api", f"repos/{repo}/issues/{issue_number}/comments"])
307
+ existing = None
308
+ for comment in comments:
309
+ if marker in comment.get("body", ""):
310
+ existing = comment
311
+ break
312
+
313
+ comment_body = f"{marker}\n{body}".strip() + "\n"
314
+ if existing:
315
+ comment_id = existing["id"]
316
+ gh_json(
317
+ [
318
+ "api",
319
+ f"repos/{repo}/issues/comments/{comment_id}",
320
+ "--method",
321
+ "PATCH",
322
+ "-f",
323
+ f"body={comment_body}",
324
+ ]
325
+ )
326
+ return int(comment_id)
327
+
328
+ created = gh_json(
329
+ [
330
+ "api",
331
+ f"repos/{repo}/issues/{issue_number}/comments",
332
+ "--method",
333
+ "POST",
334
+ "-f",
335
+ f"body={comment_body}",
336
+ ]
337
+ )
338
+ return int(created["id"])
339
+
340
+
341
+ def discover_project_from_issue(repo: str, issue_number: int) -> dict[str, Any] | None:
342
+ owner, name = repo.split("/", 1)
343
+ query = """
344
+ query($owner:String!, $name:String!, $number:Int!) {
345
+ repository(owner:$owner, name:$name) {
346
+ issue(number:$number) {
347
+ projectItems(first:20) {
348
+ nodes {
349
+ id
350
+ project {
351
+ id
352
+ number
353
+ title
354
+ closed
355
+ owner {
356
+ __typename
357
+ login
358
+ }
359
+ }
360
+ }
361
+ }
362
+ }
363
+ }
364
+ }
365
+ """
366
+ try:
367
+ data = gh_graphql(query, {"owner": owner, "name": name, "number": issue_number})
368
+ except subprocess.CalledProcessError:
369
+ return None
370
+ nodes = (((data or {}).get("data") or {}).get("repository") or {}).get("issue", {}).get("projectItems", {}).get("nodes", [])
371
+ open_nodes = [node for node in nodes if not ((node.get("project") or {}).get("closed"))]
372
+ if len(open_nodes) == 1:
373
+ project = open_nodes[0]["project"]
374
+ return {
375
+ "owner": project["owner"]["login"],
376
+ "number": int(project["number"]),
377
+ "title": project["title"],
378
+ }
379
+ return None
380
+
381
+
382
+ def resolve_project_status_components(owner: str, project_number: int, status_field_name: str, option_name: str, repo: str, issue_number: int) -> tuple[str, str, str, str]:
383
+ project = gh_json(["project", "view", str(project_number), "--owner", owner, "--format", "json"])
384
+ project_id = project.get("id")
385
+ if not project_id:
386
+ raise SystemExit(f"Could not resolve project id for {owner} project {project_number}")
387
+
388
+ fields = gh_json(["project", "field-list", str(project_number), "--owner", owner, "--format", "json"])
389
+ status_field = next((field for field in fields if field.get("name") == status_field_name), None)
390
+ if not status_field:
391
+ raise SystemExit(f"Project field not found: {status_field_name}")
392
+ field_id = status_field.get("id")
393
+ if not field_id:
394
+ raise SystemExit(f"Project field id missing for {status_field_name}")
395
+
396
+ options = status_field.get("options") or []
397
+ option = next((entry for entry in options if entry.get("name") == option_name), None)
398
+ if not option:
399
+ available = ", ".join(entry.get("name", "?") for entry in options)
400
+ raise SystemExit(f"Project option not found: {option_name}. Available: {available}")
401
+ option_id = option.get("id")
402
+ if not option_id:
403
+ raise SystemExit(f"Project option id missing for {option_name}")
404
+
405
+ owner_name, repo_name = repo.split("/", 1)
406
+ items = gh_json(["project", "item-list", str(project_number), "--owner", owner, "--format", "json", "--limit", "200"])
407
+ item_id = None
408
+ for item in items:
409
+ content = item.get("content") or {}
410
+ if int(content.get("number", -1)) != int(issue_number):
411
+ continue
412
+ repository = content.get("repository") or {}
413
+ repo_owner = (repository.get("owner") or {}).get("login")
414
+ repo_name_value = repository.get("name")
415
+ if repo_owner == owner_name and repo_name_value == repo_name:
416
+ item_id = item.get("id")
417
+ break
418
+
419
+ if not item_id:
420
+ raise SystemExit(f"Project item for issue {repo}#{issue_number} not found in {owner} project {project_number}")
421
+ return (
422
+ project_id if isinstance(project_id, str) else str(project_id),
423
+ field_id if isinstance(field_id, str) else str(field_id),
424
+ option_id if isinstance(option_id, str) else str(option_id),
425
+ item_id if isinstance(item_id, str) else str(item_id),
426
+ )
427
+
428
+
429
+ def set_project_single_select_status(owner: str, project_number: int, status_field_name: str, option_name: str, repo: str, issue_number: int) -> dict[str, str]:
430
+ project_id, field_id, option_id, item_id = resolve_project_status_components(owner, project_number, status_field_name, option_name, repo, issue_number)
431
+ run(
432
+ [
433
+ "gh",
434
+ "project",
435
+ "item-edit",
436
+ "--id",
437
+ item_id,
438
+ "--project-id",
439
+ project_id,
440
+ "--field-id",
441
+ field_id,
442
+ "--single-select-option-id",
443
+ option_id,
444
+ ]
445
+ )
446
+ return {
447
+ "project_id": project_id,
448
+ "item_id": item_id,
449
+ "field_id": field_id,
450
+ "option_id": option_id,
451
+ }
452
+
453
+
454
+ def resolve_project_context(repo: str, issue_number: int, explicit_owner: str | None = None, explicit_number: int | None = None) -> dict[str, Any] | None:
455
+ if explicit_owner and explicit_number:
456
+ return {"owner": explicit_owner, "number": int(explicit_number), "source": "explicit"}
457
+ discovered = discover_project_from_issue(repo, issue_number)
458
+ if discovered:
459
+ discovered["source"] = "issue-project-item"
460
+ return discovered
461
+ return None
462
+
463
+
464
+ def load_status(project_root: str | Path, change_id: str) -> tuple[Path, dict[str, Any]]:
465
+ path = Path(project_root).expanduser().resolve() / "specs" / "changes" / change_id / "status.json"
466
+ if not path.exists():
467
+ raise SystemExit(f"Missing status.json: {path}")
468
+ return path, read_json(path)
469
+
470
+
471
+ def save_status(path: Path, status: dict[str, Any]) -> None:
472
+ status["updated_at"] = now_iso()
473
+ write_json(path, status)
474
+
475
+
476
+ def maybe_relative(path: str | Path, *, base: str | Path) -> str:
477
+ try:
478
+ return str(Path(path).resolve().relative_to(Path(base).resolve()))
479
+ except ValueError:
480
+ return str(Path(path).resolve())