nobrainer-tech-flow 0.0.0-stage → 2.4.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 (151) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +18 -0
  3. package/.claude-plugin/plugin.json +21 -0
  4. package/.codex-plugin/plugin.json +47 -0
  5. package/.opencode/INSTALL.md +37 -0
  6. package/.opencode/plugins/nobrainer-tech-skills.js +66 -0
  7. package/.pi/extensions/nobrainer-tech-skills.js +64 -0
  8. package/AGENTS.md +205 -0
  9. package/CLAUDE.md +205 -0
  10. package/CONTRIBUTING.md +70 -0
  11. package/GEMINI.md +1 -0
  12. package/LICENSE +21 -0
  13. package/README.md +659 -2
  14. package/SECURITY.md +33 -0
  15. package/adapters/bootstrap.md +27 -0
  16. package/assets/nobrainer-tech-logo.svg +1 -0
  17. package/assets/nobrainer-workflow.svg +155 -0
  18. package/assets/plugin-logo.png +0 -0
  19. package/bin/nobrainer-tech-flow.mjs +48 -0
  20. package/docs/BOUNDED_RUNNER.md +60 -0
  21. package/docs/CHAT_PLUGINS.md +29 -0
  22. package/docs/COMPATIBILITY.md +371 -0
  23. package/docs/INSTALL.md +626 -0
  24. package/docs/MIGRATION_TO_FLOW.md +117 -0
  25. package/docs/NAMING.md +39 -0
  26. package/docs/PRIVACY.md +9 -0
  27. package/docs/RELEASING.md +24 -0
  28. package/docs/SESSION_RESTART.md +70 -0
  29. package/docs/SKILL_CURATION.md +124 -0
  30. package/docs/TESTING.md +232 -0
  31. package/docs/TRY_IT.md +109 -0
  32. package/docs/releases/v1.0.0.md +81 -0
  33. package/docs/releases/v1.1.0.md +85 -0
  34. package/docs/releases/v1.10.0.md +34 -0
  35. package/docs/releases/v1.11.0-publication-readback.md +25 -0
  36. package/docs/releases/v1.11.0.md +42 -0
  37. package/docs/releases/v1.12.0.md +30 -0
  38. package/docs/releases/v1.13.0.md +46 -0
  39. package/docs/releases/v1.14.0.md +43 -0
  40. package/docs/releases/v1.14.1.md +21 -0
  41. package/docs/releases/v1.2.0.md +87 -0
  42. package/docs/releases/v1.2.1.md +92 -0
  43. package/docs/releases/v1.3.0-publication-readback.md +69 -0
  44. package/docs/releases/v1.3.0.md +66 -0
  45. package/docs/releases/v1.3.1-publication-readback.md +76 -0
  46. package/docs/releases/v1.3.1.md +59 -0
  47. package/docs/releases/v1.4.0-publication-readback.md +81 -0
  48. package/docs/releases/v1.4.0.md +43 -0
  49. package/docs/releases/v1.5.0-publication-readback.md +76 -0
  50. package/docs/releases/v1.5.0.md +54 -0
  51. package/docs/releases/v1.6.0.md +64 -0
  52. package/docs/releases/v1.6.1-publication-readback.md +53 -0
  53. package/docs/releases/v1.6.1.md +82 -0
  54. package/docs/releases/v1.7.0.md +42 -0
  55. package/docs/releases/v1.7.1-publication-readback.md +64 -0
  56. package/docs/releases/v1.7.1.md +49 -0
  57. package/docs/releases/v1.8.0.md +63 -0
  58. package/docs/releases/v1.8.1.md +32 -0
  59. package/docs/releases/v1.9.0.md +33 -0
  60. package/docs/releases/v2.0.0-candidate.md +28 -0
  61. package/docs/releases/v2.0.0.md +53 -0
  62. package/docs/releases/v2.0.1.md +135 -0
  63. package/docs/releases/v2.0.2.md +189 -0
  64. package/docs/releases/v2.1.0.md +64 -0
  65. package/docs/releases/v2.1.1.md +43 -0
  66. package/docs/releases/v2.2.0.md +64 -0
  67. package/docs/releases/v2.2.1.md +11 -0
  68. package/docs/releases/v2.3.0.md +97 -0
  69. package/docs/releases/v2.3.1.md +71 -0
  70. package/docs/releases/v2.3.2.md +242 -0
  71. package/docs/releases/v2.4.0.md +126 -0
  72. package/docs/releases/v2.4.1.md +63 -0
  73. package/gemini-extension.json +6 -0
  74. package/hooks/claude-hooks.json +17 -0
  75. package/hooks/hooks-cursor.json +10 -0
  76. package/hooks/run-hook.cmd +36 -0
  77. package/hooks/session-start +38 -0
  78. package/package.json +69 -4
  79. package/plugin.json +51 -0
  80. package/scripts/build_plugin.py +50 -0
  81. package/scripts/check_flow_update.py +18 -0
  82. package/scripts/check_public_commit.py +86 -0
  83. package/scripts/fetch_public_refs.py +69 -0
  84. package/scripts/install.py +600 -0
  85. package/scripts/install.sh +1420 -0
  86. package/scripts/install_guards.py +279 -0
  87. package/scripts/install_personalization.py +527 -0
  88. package/scripts/install_skills.py +951 -0
  89. package/scripts/npm_install.py +85 -0
  90. package/scripts/recommend_flow_setup.py +1537 -0
  91. package/scripts/validate_skills.py +465 -0
  92. package/skills/nobrainer-auto-fine-tune/SKILL.md +79 -0
  93. package/skills/nobrainer-auto-fine-tune/references/capacity-audit.md +49 -0
  94. package/skills/nobrainer-autoimprove/SKILL.md +212 -0
  95. package/skills/nobrainer-autoimprove/scripts/count_words.py +22 -0
  96. package/skills/nobrainer-browser/SKILL.md +234 -0
  97. package/skills/nobrainer-browser/references/cdp-profile-restart.md +93 -0
  98. package/skills/nobrainer-build/SKILL.md +173 -0
  99. package/skills/nobrainer-codex-context/SKILL.md +111 -0
  100. package/skills/nobrainer-codex-context/scripts/codex_context.py +175 -0
  101. package/skills/nobrainer-decide/SKILL.md +198 -0
  102. package/skills/nobrainer-dispatcher/SKILL.md +256 -0
  103. package/skills/nobrainer-dispatcher/agents/openai.yaml +4 -0
  104. package/skills/nobrainer-rca/SKILL.md +163 -0
  105. package/skills/nobrainer-research/SKILL.md +131 -0
  106. package/skills/nobrainer-review/SKILL.md +195 -0
  107. package/skills/nobrainer-security/SKILL.md +166 -0
  108. package/skills/nobrainer-sessions/SKILL.md +223 -0
  109. package/skills/nobrainer-sessions/references/evidence-receipt.md +55 -0
  110. package/skills/nobrainer-sessions/references/protocol.md +337 -0
  111. package/skills/nobrainer-sessions/references/session-restart.md +255 -0
  112. package/skills/nobrainer-sessions/scripts/evidence_receipt.py +373 -0
  113. package/skills/nobrainer-sessions/scripts/restart_gate.py +140 -0
  114. package/skills/nobrainer-sessions/scripts/run_bounded.py +390 -0
  115. package/skills/nobrainer-sessions/scripts/session_title.py +43 -0
  116. package/skills/nobrainer-skill-doctor/SKILL.md +149 -0
  117. package/skills/nobrainer-skill-doctor/references/astra.md +19 -0
  118. package/skills/nobrainer-skill-doctor/scripts/audit.py +100 -0
  119. package/skills/nobrainer-skill-doctor/scripts/context_candidates.py +133 -0
  120. package/skills/nobrainer-spec-driven-development/SKILL.md +132 -0
  121. package/skills/nobrainer-spec-driven-development/references/spec-template.md +114 -0
  122. package/skills/nobrainer-team/SKILL.md +165 -0
  123. package/skills/nobrainer-team/references/team-plan.md +64 -0
  124. package/skills/nobrainer-tech-flow/SKILL.md +239 -0
  125. package/skills/nobrainer-tech-flow/VERSION +1 -0
  126. package/skills/nobrainer-tech-flow/references/autopilot.md +72 -0
  127. package/skills/nobrainer-tech-flow/references/codex-routing.md +21 -0
  128. package/skills/nobrainer-tech-flow/references/communication.md +108 -0
  129. package/skills/nobrainer-tech-flow/references/correction-hooks.md +110 -0
  130. package/skills/nobrainer-tech-flow/references/daily-update.md +28 -0
  131. package/skills/nobrainer-tech-flow/references/delivery.md +243 -0
  132. package/skills/nobrainer-tech-flow/references/idea-to-delivery.md +82 -0
  133. package/skills/nobrainer-tech-flow/references/long-run-state.md +209 -0
  134. package/skills/nobrainer-tech-flow/references/model-routing.md +83 -0
  135. package/skills/nobrainer-tech-flow/references/optional-decisions.md +83 -0
  136. package/skills/nobrainer-tech-flow/references/project-start.md +22 -0
  137. package/skills/nobrainer-tech-flow/references/routing.md +125 -0
  138. package/skills/nobrainer-tech-flow/references/setup.md +180 -0
  139. package/skills/nobrainer-tech-flow/references/worktrees.md +138 -0
  140. package/skills/nobrainer-tech-flow/scripts/check_flow_update.py +298 -0
  141. package/skills/nobrainer-tech-flow/scripts/cleanup_worktree.py +327 -0
  142. package/skills/nobrainer-tech-flow/scripts/decision_config.py +107 -0
  143. package/skills/nobrainer-tech-flow/scripts/flow_guard.py +252 -0
  144. package/skills/nobrainer-tech-flow/scripts/typed_decisions.py +210 -0
  145. package/skills/nobrainer-wiki/SKILL.md +164 -0
  146. package/skills/nobrainer-wiki/references/setup.md +88 -0
  147. package/skills/nobrainer-writing/SKILL.md +272 -0
  148. package/skills/nobrainer-writing/agents/openai.yaml +4 -0
  149. package/skills/nobrainer-writing/references/brief-artifacts.md +357 -0
  150. package/skills/nobrainer-writing/references/plain-technical-english.md +76 -0
  151. package/skills/nobrainer-writing/references/research.md +86 -0
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only daily check for a newer nobrainer-tech-flow GitHub release."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import datetime as dt
8
+ import json
9
+ import os
10
+ import re
11
+ import sys
12
+ import urllib.error
13
+ import urllib.request
14
+ from pathlib import Path
15
+ from typing import Any
16
+ from urllib.parse import quote, urlsplit
17
+
18
+
19
+ RELEASE_API = "https://api.github.com/repos/nobrainer-tech/nobrainer-tech-flow/releases/latest"
20
+ TIMEOUT_SECONDS = 5
21
+ MAX_RESPONSE_BYTES = 256 * 1024
22
+ SUPPORTED_CLIENTS = ("agents", "claude", "codex", "copilot", "opencode")
23
+ VERSION_RE = re.compile(
24
+ r"^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
25
+ r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$"
26
+ )
27
+
28
+
29
+ class UpdateCheckError(Exception):
30
+ """A bounded API or local-state failure suitable for a short CLI report."""
31
+
32
+
33
+ def parse_version(value: str) -> tuple[int, int, int, tuple[tuple[int, Any], ...] | None]:
34
+ """Parse SemVer without guessing from arbitrary version strings."""
35
+ match = VERSION_RE.fullmatch(value.strip())
36
+ if not match:
37
+ raise UpdateCheckError(f"unsupported version format: {value!r}")
38
+ core = tuple(int(match.group(index)) for index in (1, 2, 3))
39
+ prerelease = match.group(4)
40
+ if prerelease is None:
41
+ return core[0], core[1], core[2], None
42
+ parts: list[tuple[int, Any]] = []
43
+ for item in prerelease.split("."):
44
+ if item.isdigit():
45
+ if len(item) > 1 and item.startswith("0"):
46
+ raise UpdateCheckError(f"invalid numeric prerelease identifier: {item!r}")
47
+ parts.append((0, int(item)))
48
+ else:
49
+ parts.append((1, item))
50
+ return core[0], core[1], core[2], tuple(parts)
51
+
52
+
53
+ def is_newer(latest: str, installed: str) -> bool:
54
+ """Return whether latest is strictly newer according to SemVer precedence."""
55
+ left = parse_version(latest)
56
+ right = parse_version(installed)
57
+ if left[:3] != right[:3]:
58
+ return left[:3] > right[:3]
59
+ left_pre, right_pre = left[3], right[3]
60
+ if left_pre is None:
61
+ return right_pre is not None
62
+ if right_pre is None:
63
+ return False
64
+ for left_item, right_item in zip(left_pre, right_pre):
65
+ if left_item != right_item:
66
+ # Numeric identifiers have lower precedence than non-numeric ones.
67
+ if left_item[0] != right_item[0]:
68
+ return left_item[0] < right_item[0]
69
+ return left_item[1] > right_item[1]
70
+ return len(left_pre) > len(right_pre)
71
+
72
+
73
+ def fetch_latest_release(timeout: float = TIMEOUT_SECONDS) -> tuple[str, str]:
74
+ """Fetch only the public latest-release metadata; never execute remote data."""
75
+ request = urllib.request.Request(
76
+ RELEASE_API,
77
+ headers={
78
+ "Accept": "application/vnd.github+json",
79
+ "User-Agent": "NoBrainer-Tech-Flow-update-check",
80
+ "X-GitHub-Api-Version": "2022-11-28",
81
+ },
82
+ )
83
+ try:
84
+ with urllib.request.urlopen(request, timeout=timeout) as response:
85
+ raw = response.read(MAX_RESPONSE_BYTES + 1)
86
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
87
+ raise UpdateCheckError(f"GitHub release check failed: {type(exc).__name__}") from None
88
+ if len(raw) > MAX_RESPONSE_BYTES:
89
+ raise UpdateCheckError("GitHub release metadata exceeded the size limit")
90
+ try:
91
+ data = json.loads(raw.decode("utf-8"))
92
+ except (UnicodeDecodeError, json.JSONDecodeError):
93
+ raise UpdateCheckError("GitHub returned invalid release metadata") from None
94
+ if not isinstance(data, dict) or data.get("draft") is not False or data.get("prerelease") is not False:
95
+ raise UpdateCheckError("GitHub response is not a published stable release")
96
+ tag = data.get("tag_name")
97
+ url = data.get("html_url")
98
+ if not isinstance(tag, str) or not isinstance(url, str):
99
+ raise UpdateCheckError("GitHub release metadata is missing a valid tag or release URL")
100
+ parse_version(tag)
101
+ parsed_url = urlsplit(url)
102
+ expected_path = "/nobrainer-tech/nobrainer-tech-flow/releases/tag/" + quote(tag, safe="")
103
+ if (
104
+ parsed_url.scheme != "https"
105
+ or parsed_url.netloc != "github.com"
106
+ or parsed_url.path != expected_path
107
+ or parsed_url.query
108
+ or parsed_url.fragment
109
+ ):
110
+ raise UpdateCheckError("GitHub release URL does not match the official repository and tag")
111
+ return tag, url
112
+
113
+
114
+ def state_root() -> Path:
115
+ """Return a portable per-user state directory outside the source checkout."""
116
+ override = os.environ.get("NOBRAINER_TECH_FLOW_STATE_HOME")
117
+ if override:
118
+ return Path(override).expanduser() / "update-checks"
119
+ if sys.platform == "darwin":
120
+ base = Path.home() / "Library" / "Application Support"
121
+ elif os.name == "nt":
122
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
123
+ else:
124
+ base = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
125
+ return base / "nobrainer-tech-flow" / "update-checks"
126
+
127
+
128
+ def _read_state(path: Path) -> dict[str, Any]:
129
+ try:
130
+ value = json.loads(path.read_text(encoding="utf-8"))
131
+ except FileNotFoundError:
132
+ return {"schema_version": 1, "checks": {}, "latest_verified": None}
133
+ except (OSError, json.JSONDecodeError):
134
+ return {"schema_version": 1, "checks": {}, "latest_verified": None}
135
+ if not isinstance(value, dict) or value.get("schema_version") != 1:
136
+ return {"schema_version": 1, "checks": {}, "latest_verified": None}
137
+ if not isinstance(value.get("checks"), dict):
138
+ value["checks"] = {}
139
+ return value
140
+
141
+
142
+ def _write_state(path: Path, state: dict[str, Any]) -> None:
143
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
144
+ try:
145
+ os.chmod(path.parent, 0o700)
146
+ except OSError:
147
+ pass
148
+ temp = path.with_suffix(f".{os.getpid()}.tmp")
149
+ try:
150
+ temp.write_text(json.dumps(state, sort_keys=True, indent=2) + "\n", encoding="utf-8")
151
+ try:
152
+ os.chmod(temp, 0o600)
153
+ except OSError:
154
+ pass
155
+ temp.replace(path)
156
+ finally:
157
+ try:
158
+ temp.unlink()
159
+ except FileNotFoundError:
160
+ pass
161
+
162
+
163
+ def read_installed_version(path: Path | None = None) -> str:
164
+ version_file = path or Path(__file__).resolve().parents[1] / "VERSION"
165
+ try:
166
+ version = version_file.read_text(encoding="utf-8").strip()
167
+ except OSError:
168
+ raise UpdateCheckError(
169
+ "installed skill version is unavailable; include its VERSION file or pass --installed-version"
170
+ ) from None
171
+ if not version:
172
+ raise UpdateCheckError("installed skill VERSION file is empty; pass --installed-version")
173
+ parse_version(version)
174
+ return version
175
+
176
+
177
+ def check_update(
178
+ client: str,
179
+ installed: str,
180
+ *,
181
+ force: bool = False,
182
+ unattended: bool = False,
183
+ now: dt.datetime | None = None,
184
+ root: Path | None = None,
185
+ fetcher=fetch_latest_release,
186
+ ) -> dict[str, Any]:
187
+ """Check/cached-read one client and installed ref for the local calendar day."""
188
+ if client not in SUPPORTED_CLIENTS:
189
+ raise UpdateCheckError(f"unsupported client: {client!r}")
190
+ parse_version(installed)
191
+ today = (now or dt.datetime.now().astimezone()).date().isoformat()
192
+ client_root = (root or state_root()) / client
193
+ path = client_root / "state.json"
194
+ state = _read_state(path)
195
+ key = f"{today}|{installed}"
196
+ cached = state["checks"].get(key)
197
+ if isinstance(cached, dict) and not force:
198
+ return {**cached, "cached": True, "unattended": unattended}
199
+
200
+ checked_at = (now or dt.datetime.now().astimezone()).isoformat(timespec="seconds")
201
+ try:
202
+ latest, release_url = fetcher()
203
+ parse_version(latest)
204
+ result: dict[str, Any] = {
205
+ "date": today,
206
+ "checked_at": checked_at,
207
+ "client": client,
208
+ "installed_ref": installed,
209
+ "latest_ref": latest,
210
+ "release_url": release_url,
211
+ "status": "UPDATE_AVAILABLE" if is_newer(latest, installed) else "CURRENT",
212
+ "stale": False,
213
+ }
214
+ state["latest_verified"] = {
215
+ "latest_ref": latest,
216
+ "release_url": release_url,
217
+ "checked_at": checked_at,
218
+ "date": today,
219
+ }
220
+ except UpdateCheckError as exc:
221
+ previous = state.get("latest_verified")
222
+ result = {
223
+ "date": today,
224
+ "checked_at": checked_at,
225
+ "client": client,
226
+ "installed_ref": installed,
227
+ "latest_ref": previous.get("latest_ref") if isinstance(previous, dict) else None,
228
+ "release_url": previous.get("release_url") if isinstance(previous, dict) else None,
229
+ "status": "CHECK_FAILED",
230
+ "error": str(exc),
231
+ "stale": isinstance(previous, dict),
232
+ "previously_checked_at": previous.get("checked_at") if isinstance(previous, dict) else None,
233
+ }
234
+ result["cached"] = False
235
+ result["unattended"] = unattended
236
+ state["checks"][key] = result
237
+ # Bound local state while retaining the latest known successful release.
238
+ if len(state["checks"]) > 90:
239
+ ordered = sorted(state["checks"].items(), key=lambda item: item[1].get("checked_at", ""))
240
+ for old_key, _ in ordered[: len(state["checks"]) - 90]:
241
+ del state["checks"][old_key]
242
+ try:
243
+ _write_state(path, state)
244
+ except OSError as exc:
245
+ raise UpdateCheckError(f"could not save update-check state: {type(exc).__name__}") from None
246
+ return result
247
+
248
+
249
+ def _parser() -> argparse.ArgumentParser:
250
+ parser = argparse.ArgumentParser(description=__doc__)
251
+ parser.add_argument("--client", required=True, choices=SUPPORTED_CLIENTS)
252
+ parser.add_argument(
253
+ "--installed-version",
254
+ help="Override the version in the installed skill's adjacent VERSION file",
255
+ )
256
+ parser.add_argument("--force", action="store_true", help="Fetch again even if already checked today")
257
+ parser.add_argument("--unattended", action="store_true", help="Emit a stable machine-readable line for schedulers")
258
+ return parser
259
+
260
+
261
+ def main(argv: list[str] | None = None) -> int:
262
+ args = _parser().parse_args(argv)
263
+ try:
264
+ installed = args.installed_version or read_installed_version()
265
+ result = check_update(args.client, installed, force=args.force, unattended=args.unattended)
266
+ except UpdateCheckError as exc:
267
+ print(f"UPDATE_CHECK: ERROR: {exc}", file=sys.stderr)
268
+ return 2
269
+
270
+ status = result["status"]
271
+ if args.unattended:
272
+ fields = [
273
+ f"status={status}",
274
+ f"client={result['client']}",
275
+ f"installed_ref={result['installed_ref']}",
276
+ f"latest_ref={result.get('latest_ref') or 'unknown'}",
277
+ f"cached={str(result['cached']).lower()}",
278
+ f"stale={str(result.get('stale', False)).lower()}",
279
+ ]
280
+ print("UPDATE_CHECK: " + " ".join(fields))
281
+ else:
282
+ print(f"UPDATE_CHECK: {status} client={result['client']} installed={result['installed_ref']} latest={result.get('latest_ref') or 'unknown'}")
283
+ if status == "UPDATE_AVAILABLE":
284
+ print(f"Release: {result['release_url']}")
285
+ print(
286
+ f"Next: obtain and review release tag {result['latest_ref']} from the official repository; "
287
+ "verify its source and release evidence, then run the installer dry-run, apply "
288
+ "the reviewed update, and read back the installed files and version."
289
+ )
290
+ elif status == "CHECK_FAILED":
291
+ print(f"Details: {result['error']}")
292
+ if result.get("stale"):
293
+ print(f"Last verified release: {result['latest_ref']} at {result['previously_checked_at']} (stale; verify online before acting)")
294
+ return 0 if status in ("CURRENT", "UPDATE_AVAILABLE") else 1
295
+
296
+
297
+ if __name__ == "__main__":
298
+ raise SystemExit(main())
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env python3
2
+ """Safely remove one task-owned worktree after its exact PR is merged."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import datetime as dt
8
+ import json
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import uuid
14
+ from pathlib import Path
15
+ from urllib.parse import urlsplit
16
+
17
+
18
+ SHA_RE = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
19
+ WRITER_MAX_AGE_SECONDS = 60
20
+
21
+
22
+ class CleanupError(Exception):
23
+ """A failed safety gate or an unverifiable cleanup result."""
24
+
25
+
26
+ def run(
27
+ argv: list[str],
28
+ *,
29
+ cwd: Path | None = None,
30
+ allow_clean_no_match: bool = False,
31
+ ) -> subprocess.CompletedProcess[str]:
32
+ try:
33
+ result = subprocess.run(
34
+ argv,
35
+ cwd=cwd,
36
+ text=True,
37
+ encoding="utf-8",
38
+ errors="replace",
39
+ stdin=subprocess.DEVNULL,
40
+ stdout=subprocess.PIPE,
41
+ stderr=subprocess.PIPE,
42
+ check=False,
43
+ )
44
+ except OSError as exc:
45
+ raise CleanupError(f"cannot run {Path(argv[0]).name}: {type(exc).__name__}") from None
46
+ if result.returncode and not (
47
+ allow_clean_no_match
48
+ and result.returncode == 1
49
+ and not result.stdout.strip()
50
+ and not result.stderr.strip()
51
+ ):
52
+ detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}"
53
+ raise CleanupError(f"command failed ({Path(argv[0]).name}): {detail[:500]}")
54
+ return result
55
+
56
+
57
+ def git(git_bin: str, repo: Path, *args: str) -> str:
58
+ return run([git_bin, "-C", str(repo), *args]).stdout.strip()
59
+
60
+
61
+ def required_string(obj: dict, key: str) -> str:
62
+ value = obj.get(key)
63
+ if not isinstance(value, str) or not value.strip():
64
+ raise CleanupError(f"manifest is missing non-empty {key}")
65
+ return value.strip()
66
+
67
+
68
+ def read_manifest(path: Path) -> dict:
69
+ try:
70
+ data = json.loads(path.read_text(encoding="utf-8"))
71
+ except (OSError, json.JSONDecodeError):
72
+ raise CleanupError("cannot read a valid JSON task manifest") from None
73
+ if not isinstance(data, dict) or data.get("schema_version") != 1:
74
+ raise CleanupError("unsupported worktree manifest schema")
75
+ return data
76
+
77
+
78
+ def verify_writer_readback(data: dict, worktree: Path, now: dt.datetime | None = None) -> None:
79
+ readback = data.get("writer_readback")
80
+ if not isinstance(readback, dict):
81
+ raise CleanupError("missing authoritative host writer readback")
82
+ if readback.get("status") != "IDLE" or readback.get("active_writers") != []:
83
+ raise CleanupError("host writer readback does not prove IDLE with no active writers")
84
+ if Path(required_string(readback, "worktree_path")).expanduser().resolve() != worktree:
85
+ raise CleanupError("writer readback names a different worktree")
86
+ required_string(readback, "source")
87
+ try:
88
+ observed = dt.datetime.fromisoformat(required_string(readback, "observed_at").replace("Z", "+00:00"))
89
+ except ValueError:
90
+ raise CleanupError("writer readback has an invalid observed_at timestamp") from None
91
+ if observed.tzinfo is None:
92
+ raise CleanupError("writer readback timestamp must include a timezone")
93
+ now = now or dt.datetime.now(dt.timezone.utc)
94
+ age = (now - observed.astimezone(dt.timezone.utc)).total_seconds()
95
+ if age < -5 or age > WRITER_MAX_AGE_SECONDS:
96
+ raise CleanupError("host writer readback is stale or from the future")
97
+
98
+
99
+ def remote_identity(url: str) -> tuple[str, str]:
100
+ """Extract host and owner/repository from common GitHub remote URL forms."""
101
+ if re.match(r"^[^/@:]+@[^:]+:.+$", url):
102
+ host, path = url.split(":", 1)
103
+ host = host.rsplit("@", 1)[1]
104
+ else:
105
+ parsed = urlsplit(url)
106
+ if parsed.scheme not in {"https", "ssh", "git"} or not parsed.hostname:
107
+ raise CleanupError("task remote is not a verifiable GitHub URL")
108
+ host, path = parsed.hostname, parsed.path.lstrip("/")
109
+ path = path.removesuffix(".git").strip("/")
110
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", path):
111
+ raise CleanupError("task remote URL has no exact owner/repository identity")
112
+ return host.lower(), path.lower()
113
+
114
+
115
+ def parse_worktrees(value: str) -> list[dict[str, str]]:
116
+ entries: list[dict[str, str]] = []
117
+ for block in value.split("\n\n"):
118
+ entry: dict[str, str] = {}
119
+ for line in block.splitlines():
120
+ key, _, rest = line.partition(" ")
121
+ if key in {"worktree", "HEAD", "branch"}:
122
+ entry[key] = rest
123
+ if "worktree" in entry:
124
+ entries.append(entry)
125
+ return entries
126
+
127
+
128
+ def lsof_writer_check(lsof_bin: str, worktree: Path) -> None:
129
+ """Fail if lsof reports a process cwd or open file inside this worktree."""
130
+ worktree = worktree.resolve()
131
+ commands = [
132
+ [lsof_bin, "-nP", "-Fpcfn", "-d", "cwd"],
133
+ [lsof_bin, "-nP", "-Fpcfn", "+D", str(worktree)],
134
+ ]
135
+ for command in commands:
136
+ result = run(command, allow_clean_no_match=True)
137
+ offenders: list[str] = []
138
+ process = "unknown process"
139
+ for line in result.stdout.splitlines():
140
+ if line.startswith("p"):
141
+ process = line[1:]
142
+ elif line.startswith("n"):
143
+ raw = line[1:].removesuffix(" (deleted)")
144
+ try:
145
+ candidate = Path(raw).resolve()
146
+ if candidate == worktree or worktree in candidate.parents:
147
+ offenders.append(process)
148
+ except (OSError, RuntimeError):
149
+ continue
150
+ if offenders:
151
+ raise CleanupError("active process is using the task worktree (PID(s): " + ",".join(sorted(set(offenders))) + ")")
152
+
153
+
154
+ def verify_and_cleanup(manifest_path: Path, *, apply: bool, gh_bin: str = "gh", git_bin: str = "git") -> dict:
155
+ manifest = read_manifest(manifest_path)
156
+ repo_path = Path(required_string(manifest, "repository_path")).expanduser().resolve()
157
+ worktree = Path(required_string(manifest, "worktree_path")).expanduser().resolve()
158
+ task_id = required_string(manifest, "task_id")
159
+ remote = required_string(manifest, "remote")
160
+ repository = required_string(manifest, "repository").lower()
161
+ branch = required_string(manifest, "branch")
162
+ expected_head = required_string(manifest, "submitted_head_sha").lower()
163
+ if not repo_path.is_dir() or not worktree.is_dir() or repo_path == worktree:
164
+ raise CleanupError("repository or task worktree path is missing or ambiguous")
165
+ if not SHA_RE.fullmatch(expected_head):
166
+ raise CleanupError("submitted_head_sha must be a full 40-character commit SHA")
167
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
168
+ raise CleanupError("repository must be an exact owner/repository slug")
169
+ if remote.startswith("-") or remote not in git(git_bin, repo_path, "remote").splitlines():
170
+ raise CleanupError("manifest remote is not an exact configured Git remote")
171
+ git(git_bin, repo_path, "check-ref-format", "refs/heads/" + branch)
172
+
173
+ registered = parse_worktrees(git(git_bin, repo_path, "worktree", "list", "--porcelain"))
174
+ matching = [entry for entry in registered if Path(entry["worktree"]).resolve() == worktree]
175
+ if len(matching) != 1:
176
+ raise CleanupError("exact task worktree is not uniquely registered")
177
+ entry = matching[0]
178
+ if entry.get("branch") != "refs/heads/" + branch:
179
+ raise CleanupError("registered worktree branch does not match the task manifest")
180
+ if entry.get("HEAD", "").lower() != expected_head:
181
+ raise CleanupError("registered worktree HEAD does not match the submitted PR head")
182
+ if git(git_bin, worktree, "rev-parse", "--show-toplevel") != str(worktree):
183
+ raise CleanupError("resolved worktree root does not match the task path")
184
+ if git(git_bin, worktree, "status", "--porcelain=v1", "--untracked-files=all"):
185
+ raise CleanupError("task worktree has staged, unstaged, conflicted, or untracked files")
186
+ if git(git_bin, worktree, "clean", "-ndx"):
187
+ raise CleanupError("task worktree contains ignored or other removable user data")
188
+
189
+ remote_url = git(git_bin, repo_path, "remote", "get-url", remote)
190
+ host, remote_repo = remote_identity(remote_url)
191
+ try:
192
+ pr_number = int(manifest["pr_number"])
193
+ except (KeyError, TypeError, ValueError):
194
+ raise CleanupError("manifest pr_number must be an integer") from None
195
+ if pr_number <= 0:
196
+ raise CleanupError("manifest pr_number must be positive")
197
+
198
+ gh_repo = json.loads(run([gh_bin, "repo", "view", repository, "--json", "nameWithOwner,defaultBranchRef"]).stdout)
199
+ if not isinstance(gh_repo, dict) or str(gh_repo.get("nameWithOwner", "")).lower() != repository:
200
+ raise CleanupError("GitHub repository readback does not match the manifest repository")
201
+ default_ref = gh_repo.get("defaultBranchRef")
202
+ default_branch = default_ref.get("name") if isinstance(default_ref, dict) else None
203
+ if not isinstance(default_branch, str) or not default_branch:
204
+ raise CleanupError("GitHub did not return a verified default branch")
205
+
206
+ pr = json.loads(
207
+ run([
208
+ gh_bin,
209
+ "pr",
210
+ "view",
211
+ str(pr_number),
212
+ "--repo",
213
+ repository,
214
+ "--json",
215
+ "number,state,mergedAt,headRefName,headRefOid,baseRefName,mergeCommit,url",
216
+ ]).stdout
217
+ )
218
+ if not isinstance(pr, dict):
219
+ raise CleanupError("GitHub PR readback is invalid")
220
+ try:
221
+ returned_number = int(pr.get("number", 0))
222
+ except (TypeError, ValueError):
223
+ raise CleanupError("GitHub returned an invalid PR number") from None
224
+ if returned_number != pr_number:
225
+ raise CleanupError("GitHub returned a different PR number")
226
+ if pr.get("state") != "MERGED" or not isinstance(pr.get("mergedAt"), str) or not pr.get("mergedAt"):
227
+ raise CleanupError("PR is not verified as merged")
228
+ if pr.get("headRefName") != branch or str(pr.get("headRefOid", "")).lower() != expected_head:
229
+ raise CleanupError("PR source branch or submitted head SHA does not match this task worktree")
230
+ if pr.get("baseRefName") != default_branch:
231
+ raise CleanupError("PR target is not the verified repository default branch")
232
+ merge_commit = pr.get("mergeCommit")
233
+ merge_sha = merge_commit.get("oid") if isinstance(merge_commit, dict) else None
234
+ if not isinstance(merge_sha, str) or not SHA_RE.fullmatch(merge_sha):
235
+ raise CleanupError("GitHub did not return a valid PR merge-result SHA")
236
+ pr_url = urlsplit(str(pr.get("url", "")))
237
+ if pr_url.scheme != "https" or pr_url.hostname != host:
238
+ raise CleanupError("PR URL host does not match the task Git remote")
239
+ expected_path = f"/{repository}/pull/{pr_number}"
240
+ if pr_url.path.rstrip("/").lower() != expected_path.lower():
241
+ raise CleanupError("PR URL does not identify the exact repository and PR")
242
+ if remote_repo != repository:
243
+ raise CleanupError("task Git remote and verified PR repository do not match")
244
+
245
+ git(git_bin, repo_path, "check-ref-format", "refs/heads/" + default_branch)
246
+ fetched_ref = "refs/nobrainer-tech-flow/cleanup/" + uuid.uuid4().hex + "/base"
247
+ try:
248
+ run([
249
+ git_bin,
250
+ "-C",
251
+ str(repo_path),
252
+ "fetch",
253
+ "--no-tags",
254
+ remote,
255
+ f"+refs/heads/{default_branch}:{fetched_ref}",
256
+ ])
257
+ fetched_base = git(git_bin, repo_path, "rev-parse", fetched_ref).lower()
258
+ if not SHA_RE.fullmatch(fetched_base):
259
+ raise CleanupError("fetched target branch did not resolve to a full commit SHA")
260
+ ancestor = subprocess.run(
261
+ [git_bin, "-C", str(repo_path), "merge-base", "--is-ancestor", merge_sha, fetched_base],
262
+ stdin=subprocess.DEVNULL,
263
+ stdout=subprocess.PIPE,
264
+ stderr=subprocess.PIPE,
265
+ check=False,
266
+ text=True,
267
+ )
268
+ if ancestor.returncode != 0:
269
+ raise CleanupError("verified PR merge-result SHA is not in fetched default-branch history")
270
+ finally:
271
+ run([git_bin, "-C", str(repo_path), "update-ref", "-d", fetched_ref])
272
+
273
+ verify_writer_readback(manifest, worktree)
274
+ lsof = shutil.which("lsof")
275
+ if lsof:
276
+ lsof_writer_check(lsof, worktree)
277
+
278
+ result = {
279
+ "status": "DRY_RUN_READY" if not apply else "REMOVED",
280
+ "task_id": task_id,
281
+ "repository": repository,
282
+ "pr_number": pr_number,
283
+ "branch": branch,
284
+ "submitted_head_sha": expected_head,
285
+ "merge_sha": merge_sha.lower(),
286
+ "fetched_default_branch_sha": fetched_base,
287
+ "worktree_path": str(worktree),
288
+ "active_writer_check": "authoritative host readback is fresh and IDLE",
289
+ "local_process_check": "lsof passed" if lsof else "UNAVAILABLE; host readback only",
290
+ }
291
+ if not apply:
292
+ return result
293
+
294
+ # Re-read volatile safety gates immediately before the destructive Git operation.
295
+ verify_writer_readback(manifest, worktree)
296
+ if lsof:
297
+ lsof_writer_check(lsof, worktree)
298
+ if git(git_bin, worktree, "status", "--porcelain=v1", "--untracked-files=all") or git(
299
+ git_bin, worktree, "clean", "-ndx"
300
+ ):
301
+ raise CleanupError("worktree changed after verification; refusing removal")
302
+ run([git_bin, "-C", str(repo_path), "worktree", "remove", str(worktree)])
303
+ after = parse_worktrees(git(git_bin, repo_path, "worktree", "list", "--porcelain"))
304
+ if any(Path(item["worktree"]).resolve() == worktree for item in after):
305
+ raise CleanupError("Git removal returned but the task worktree is still registered")
306
+ result["status"] = "REMOVED"
307
+ return result
308
+
309
+
310
+ def main(argv: list[str] | None = None) -> int:
311
+ parser = argparse.ArgumentParser(description=__doc__)
312
+ parser.add_argument("--manifest", required=True, type=Path, help="exact task worktree lifecycle JSON")
313
+ parser.add_argument("--apply", action="store_true", help="remove the worktree after every safety gate passes")
314
+ parser.add_argument("--gh-bin", default="gh", help=argparse.SUPPRESS)
315
+ parser.add_argument("--git-bin", default="git", help=argparse.SUPPRESS)
316
+ args = parser.parse_args(argv)
317
+ try:
318
+ result = verify_and_cleanup(args.manifest, apply=args.apply, gh_bin=args.gh_bin, git_bin=args.git_bin)
319
+ except (CleanupError, json.JSONDecodeError) as exc:
320
+ print(json.dumps({"status": "BLOCKED", "reason": str(exc)}, sort_keys=True))
321
+ return 2
322
+ print(json.dumps(result, sort_keys=True))
323
+ return 0
324
+
325
+
326
+ if __name__ == "__main__":
327
+ sys.exit(main())