@deftai/directive-content 0.55.2 → 0.56.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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,721 @@
1
+ #!/usr/bin/env python3
2
+ """preflight_gh.py -- detection-bound gate for destructive ``gh`` verbs (#1019).
3
+
4
+ Pure stdlib, cross-platform. Mirrors :mod:`preflight_branch` (#747) in shape:
5
+
6
+ - ``0`` -- allowed: command is not destructive, OR an env-var bypass /
7
+ typed policy flag explicitly authorizes the destructive verb.
8
+ - ``1`` -- blocked: command classified as one of the destructive categories
9
+ defined by this gate and no bypass is active.
10
+ - ``2`` -- config error: malformed input, missing required argument, or
11
+ the operator passed a self-test fixture that disagrees with the
12
+ classifier (the latter is a hard failure for the ``--self-test`` mode).
13
+
14
+ Destructive categories detected by this gate (see #1019 vBRIEF for
15
+ rationale and the real-world recurrence pattern that motivates each):
16
+
17
+ - ``delete_repo`` -- ``gh repo delete <repo>`` and
18
+ ``gh api -X DELETE repos/<owner>/<repo>[/...]``. Irreversible repo
19
+ deletion via the GitHub API. This is the failure mode that prompted
20
+ the issue (a peer-tool agent deleted every company repo before
21
+ apologising).
22
+ - ``force_push_default`` -- ``git push --force`` and
23
+ ``git push --force-with-lease`` to the default branch (master/main).
24
+ The #747 branch gate refuses *commits* to master/main, but a force-push
25
+ from a feature branch that targets ``master`` was uncovered. This gate
26
+ closes the loop.
27
+ - ``admin_merge`` -- ``gh pr merge --admin`` (bypass of branch protection
28
+ required reviews). Document the rationale + escape hatch rather than
29
+ silently allow.
30
+
31
+ Intended call surfaces:
32
+
33
+ - ``.githooks/pre-push`` -- the hook reads its per-ref stdin lines and
34
+ dispatches to ``preflight_gh.py --pre-push-stdin`` so force-pushes
35
+ targeting the default branch are refused at the git layer before the
36
+ network call.
37
+ - ``task verify:destructive-gh-verbs`` -- aggregated into ``task check``.
38
+ Runs ``--self-test`` so a future edit to the classifier table that
39
+ introduces a false negative / false positive fails CI immediately.
40
+ - Agent pre-execution hooks -- ``preflight_gh.py --command "<full
41
+ command string>"`` returns three-state exit so an orchestrator can
42
+ refuse the verb before invoking ``gh`` (out of scope for v1; the
43
+ CLI surface is provided so the wiring can land additively).
44
+
45
+ Escape hatch (operator-implemented): set
46
+ ``DEFT_ALLOW_DESTRUCTIVE_GH_VERBS=1`` to bypass the gate for a single
47
+ shell. Symmetric with ``DEFT_ALLOW_DEFAULT_BRANCH_COMMIT`` (#747).
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import argparse
53
+ import os
54
+ import re
55
+ import shlex
56
+ import sys
57
+ from collections.abc import Iterable
58
+ from dataclasses import dataclass
59
+ from pathlib import Path
60
+
61
+ #: Environment variable that lets the operator bypass the destructive-verb
62
+ #: gate WITHOUT editing the typed flag. Documented in #1019 as the
63
+ #: explicit emergency-escape hatch (e.g. authorised release-cycle
64
+ #: ``gh release delete`` runs, hot-fix admin merges). When set to a
65
+ #: truthy value, this gate exits 0 with an explicit "policy bypassed"
66
+ #: message so the bypass is auditable.
67
+ ENV_BYPASS = "DEFT_ALLOW_DESTRUCTIVE_GH_VERBS"
68
+
69
+ #: Recognised truthy strings for the env-var bypass. Identical to the
70
+ #: surface used by :mod:`scripts.policy` for parity with #747.
71
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
72
+
73
+ #: Default-branch refs treated as protected against force-push by the
74
+ #: ``force_push_default`` category. Mirrors the
75
+ #: :mod:`scripts.preflight_branch` default-branch set so the two gates
76
+ #: agree on what "default" means.
77
+ DEFAULT_BRANCHES = frozenset({"master", "main"})
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Verdict:
82
+ """Result of classifying a candidate command.
83
+
84
+ ``category`` is ``None`` when the command is not destructive.
85
+ ``detail`` is a short human-readable reason; ``recovery`` is the
86
+ follow-up text the gate prints to stderr on a block so the operator
87
+ knows how to proceed.
88
+ """
89
+
90
+ allowed: bool
91
+ category: str | None
92
+ detail: str
93
+ recovery: str = ""
94
+
95
+
96
+ _OK_VERDICT = Verdict(allowed=True, category=None, detail="not destructive")
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Classifier
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ def _env_bypass_active() -> bool:
105
+ """True when ``DEFT_ALLOW_DESTRUCTIVE_GH_VERBS`` is set to a truthy value."""
106
+ raw = os.environ.get(ENV_BYPASS, "")
107
+ return raw.strip().lower() in _TRUTHY
108
+
109
+
110
+ def _tokens_from_string(command: str) -> list[str]:
111
+ """Tokenise a candidate command string into argv-like tokens.
112
+
113
+ Uses :func:`shlex.split` with ``posix=True`` -- the gate is invoked
114
+ on the operator side BEFORE the verb executes, so we can assume
115
+ POSIX-style quoting from the dispatching agent. Windows agents
116
+ that pass a literal cmd.exe string get a degraded but conservative
117
+ tokenisation: ``shlex`` falls back to whitespace splits when the
118
+ string contains no quotes, which is the common case for
119
+ ``gh repo delete owner/repo`` style invocations.
120
+ """
121
+ try:
122
+ return shlex.split(command, posix=True)
123
+ except ValueError:
124
+ # Mismatched quotes; treat as raw whitespace tokens so we still
125
+ # match the destructive sub-strings rather than failing open.
126
+ return command.split()
127
+
128
+
129
+ def _is_delete_repo(tokens: list[str]) -> Verdict | None:
130
+ """Detect ``gh repo delete ...`` and ``gh api -X DELETE repos/...``.
131
+
132
+ Both forms are irreversible. ``gh api -X DELETE`` is the surface a
133
+ bypass-conscious agent might reach for when ``gh repo delete`` is
134
+ intercepted, so it MUST be detected even when the args are split
135
+ by the equals form (``-X=DELETE`` / ``--method=DELETE``).
136
+ """
137
+ if len(tokens) < 2:
138
+ return None
139
+ head = tokens[0].lower()
140
+ if head not in {"gh", "ghx"}:
141
+ return None
142
+
143
+ # gh repo delete <owner/repo>
144
+ if tokens[1].lower() == "repo" and len(tokens) >= 3 and tokens[2].lower() == "delete":
145
+ target = tokens[3] if len(tokens) >= 4 else "<unspecified>"
146
+ return Verdict(
147
+ allowed=False,
148
+ category="delete_repo",
149
+ detail=f"gh repo delete {target}",
150
+ recovery=(
151
+ " Repo deletion is irreversible. If this is intentional:\n"
152
+ f" • set the env-var bypass for this shell: {ENV_BYPASS}=1\n"
153
+ " • or run the deletion via the GitHub web UI so the\n"
154
+ " reversible-archive prompt fires (preferred)."
155
+ ),
156
+ )
157
+
158
+ # gh api ... DELETE repos/<owner>/<repo>...
159
+ if tokens[1].lower() == "api" and _api_invocation_is_delete(tokens[2:]):
160
+ endpoint = _api_endpoint(tokens[2:])
161
+ if endpoint and _endpoint_is_repo_root(endpoint):
162
+ return Verdict(
163
+ allowed=False,
164
+ category="delete_repo",
165
+ detail=f"gh api -X DELETE {endpoint}",
166
+ recovery=(
167
+ " Repo / repo-subresource deletion via the API is\n"
168
+ " irreversible. If this is intentional:\n"
169
+ f" • set the env-var bypass for this shell: {ENV_BYPASS}=1"
170
+ ),
171
+ )
172
+
173
+ return None
174
+
175
+
176
+ def _api_invocation_is_delete(tokens: Iterable[str]) -> bool:
177
+ """True iff the gh-api token list specifies a DELETE method.
178
+
179
+ Handles ``-X DELETE``, ``-XDELETE``, ``--method DELETE``,
180
+ ``-X=DELETE``, ``--method=DELETE`` and equivalent case-insensitive
181
+ forms.
182
+ """
183
+ items = list(tokens)
184
+ for idx, tok in enumerate(items):
185
+ low = tok.lower()
186
+ if (
187
+ low in {"-x", "--method"}
188
+ and idx + 1 < len(items)
189
+ and items[idx + 1].upper() == "DELETE"
190
+ ):
191
+ return True
192
+ if low.startswith(("-x=", "--method=")):
193
+ value = tok.split("=", 1)[1]
194
+ if value.upper() == "DELETE":
195
+ return True
196
+ # -XDELETE / -Xdelete (combined short-flag form)
197
+ if low.startswith("-x") and len(low) > 2 and low[2:].upper() == "DELETE":
198
+ return True
199
+ return False
200
+
201
+
202
+ def _api_endpoint(tokens: Iterable[str]) -> str | None:
203
+ """Return the positional endpoint argument (the path after ``gh api``).
204
+
205
+ Skips both flag tokens (``-X`` / ``--method`` / ``-H`` / ``--header`` /
206
+ ``-F`` / ``-f`` / ``--field`` / ``--input``) AND the value that
207
+ follows them when the value is passed space-separated. The set
208
+ enumerated here is the closed set of ``gh api`` flags that take an
209
+ argument; anything else with a leading ``-`` is treated as a boolean
210
+ flag and skipped without consuming the next token.
211
+ """
212
+ # Note: ``gh api`` short flag for ``--raw-field`` is ``-F`` (uppercase).
213
+ # Because the caller lower-cases the token before lookup, ``-F`` is
214
+ # already covered implicitly by the ``-f`` entry, but enumerating ``-F``
215
+ # explicitly makes the contract self-documenting and avoids a duplicate-
216
+ # item ruff finding (B033) when both forms collapse to the same key.
217
+ value_taking = {
218
+ "-x", "--method",
219
+ "-h", "--header",
220
+ "-f", "--field",
221
+ "-F", "--raw-field",
222
+ "--input",
223
+ "--jq", "-q",
224
+ "--template", "-t",
225
+ "--hostname",
226
+ "--cache",
227
+ }
228
+ items = list(tokens)
229
+ idx = 0
230
+ while idx < len(items):
231
+ tok = items[idx]
232
+ low = tok.lower()
233
+ if not tok.startswith("-"):
234
+ return tok
235
+ # Flag-with-attached-value form: -X=DELETE / --method=DELETE -- skip.
236
+ if "=" in tok:
237
+ idx += 1
238
+ continue
239
+ # Combined short-flag form: -XDELETE -- skip the whole token.
240
+ if low.startswith("-x") and len(low) > 2:
241
+ idx += 1
242
+ continue
243
+ # Space-separated value form: consume the next token as the value
244
+ # so it is not mistaken for the endpoint positional.
245
+ if low in value_taking and idx + 1 < len(items):
246
+ idx += 2
247
+ continue
248
+ idx += 1
249
+ return None
250
+
251
+
252
+ def _endpoint_is_repo_root(endpoint: str) -> bool:
253
+ """True when the endpoint targets ``repos/<owner>/<repo>`` or a child.
254
+
255
+ Both ``repos/foo/bar`` and ``/repos/foo/bar/contents`` qualify --
256
+ DELETE on any repo-scoped resource is destructive enough that the
257
+ gate refuses it. The narrower case where DELETE on a label or comment
258
+ is legitimate can be escape-hatched via the env-var bypass; the
259
+ default-deny stance mirrors the #747 branch-gate disposition.
260
+ """
261
+ normalised = endpoint.lstrip("/").lower()
262
+ return normalised.startswith("repos/")
263
+
264
+
265
+ def _is_force_push_default(tokens: list[str], default_branches: frozenset[str]) -> Verdict | None:
266
+ """Detect ``git push --force[-with-lease]`` targeting the default branch.
267
+
268
+ The default-branch detection looks for ``<remote> <branch>`` form
269
+ (e.g. ``git push origin master --force``) as well as
270
+ ``HEAD:<branch>`` refspecs and ``+<branch>`` "force" refspecs.
271
+ """
272
+ if len(tokens) < 2:
273
+ return None
274
+ if tokens[0].lower() != "git" or tokens[1].lower() != "push":
275
+ return None
276
+
277
+ args = tokens[2:]
278
+ is_force = False
279
+ force_flag = ""
280
+ for tok in args:
281
+ low = tok.lower()
282
+ if low in {"-f", "--force"}:
283
+ is_force = True
284
+ force_flag = tok
285
+ break
286
+ if low.startswith("--force-with-lease"):
287
+ is_force = True
288
+ force_flag = tok
289
+ break
290
+
291
+ # ``+refspec`` is the "force" form of a refspec. It is destructive
292
+ # in the same way ``--force`` is, so we treat it as force as well.
293
+ plus_refspec_target: str | None = None
294
+ for tok in args:
295
+ if tok.startswith("+") and not tok.startswith("--"):
296
+ plus_refspec_target = tok[1:]
297
+ is_force = True
298
+ if not force_flag:
299
+ force_flag = "+<refspec>"
300
+ break
301
+
302
+ if not is_force:
303
+ return None
304
+
305
+ # Resolve target branch(es) referenced by the push.
306
+ targets = _resolve_push_targets(args, plus_refspec_target)
307
+ hit = next(
308
+ (b for b in targets if b.lower() in {x.lower() for x in default_branches}),
309
+ None,
310
+ )
311
+ if hit is None:
312
+ return None
313
+
314
+ return Verdict(
315
+ allowed=False,
316
+ category="force_push_default",
317
+ detail=f"git push {force_flag} -> default branch '{hit}'",
318
+ recovery=(
319
+ " Force-pushing the default branch overwrites shared history\n"
320
+ " and can lose commits. If this is genuinely necessary:\n"
321
+ f" • set the env-var bypass for this shell: {ENV_BYPASS}=1\n"
322
+ " • or push to a feature branch and open a PR.\n"
323
+ " See scm/github.md (## Destructive gh verbs (#1019))."
324
+ ),
325
+ )
326
+
327
+
328
+ def _resolve_push_targets(args: list[str], plus_refspec: str | None) -> list[str]:
329
+ """Extract the destination ref(s) named by a ``git push`` invocation.
330
+
331
+ Recognises three shapes:
332
+
333
+ - ``git push <remote> <branch> [...]`` -- positional branch arg
334
+ - ``git push <remote> HEAD:<branch>`` / ``<src>:<dst>`` refspecs
335
+ - ``git push <remote> +<src>:<dst>`` -- the explicit force refspec
336
+ """
337
+ targets: list[str] = []
338
+ positional = [t for t in args if not t.startswith("-")]
339
+ if plus_refspec and plus_refspec not in positional:
340
+ positional.append(plus_refspec)
341
+
342
+ if len(positional) >= 2:
343
+ # positional[0] is the remote; positional[1:] are refspecs / branches.
344
+ for token in positional[1:]:
345
+ if token.startswith("+"):
346
+ token = token[1:]
347
+ if ":" in token:
348
+ # src:dst -> we want dst
349
+ _, dst = token.split(":", 1)
350
+ targets.append(dst.removeprefix("refs/heads/"))
351
+ else:
352
+ targets.append(token.removeprefix("refs/heads/"))
353
+ return targets
354
+
355
+
356
+ def _is_admin_merge(tokens: list[str]) -> Verdict | None:
357
+ """Detect ``gh pr merge --admin`` (bypasses branch protection)."""
358
+ if len(tokens) < 3:
359
+ return None
360
+ if tokens[0].lower() not in {"gh", "ghx"}:
361
+ return None
362
+ if tokens[1].lower() != "pr" or tokens[2].lower() != "merge":
363
+ return None
364
+ if not any(tok.lower() == "--admin" for tok in tokens[3:]):
365
+ return None
366
+ return Verdict(
367
+ allowed=False,
368
+ category="admin_merge",
369
+ detail="gh pr merge --admin",
370
+ recovery=(
371
+ " --admin bypasses branch-protection required reviews.\n"
372
+ " If this is intentional (release-cycle hot-fix, agreed\n"
373
+ " rollback, etc.):\n"
374
+ f" • set the env-var bypass for this shell: {ENV_BYPASS}=1\n"
375
+ " Otherwise: request review through the normal flow."
376
+ ),
377
+ )
378
+
379
+
380
+ def classify_command(
381
+ command: str,
382
+ *,
383
+ default_branches: frozenset[str] = DEFAULT_BRANCHES,
384
+ ) -> Verdict:
385
+ """Classify a candidate command string. Pure function (no env reads).
386
+
387
+ Callers that want env-var bypass semantics should consult
388
+ :func:`evaluate_command` (which wraps this) -- this primitive is
389
+ bypass-blind so unit tests can drive every classifier branch
390
+ without juggling environment state.
391
+ """
392
+ tokens = _tokens_from_string(command)
393
+ if not tokens:
394
+ return _OK_VERDICT
395
+
396
+ for detector in (
397
+ _is_delete_repo,
398
+ _is_admin_merge,
399
+ ):
400
+ verdict = detector(tokens)
401
+ if verdict is not None:
402
+ return verdict
403
+
404
+ force_push = _is_force_push_default(tokens, default_branches)
405
+ if force_push is not None:
406
+ return force_push
407
+
408
+ return _OK_VERDICT
409
+
410
+
411
+ def evaluate_command(
412
+ command: str,
413
+ *,
414
+ default_branches: frozenset[str] = DEFAULT_BRANCHES,
415
+ ) -> tuple[int, str]:
416
+ """Evaluate a candidate command and produce (exit_code, message).
417
+
418
+ Honours ``DEFT_ALLOW_DESTRUCTIVE_GH_VERBS`` as the env-var bypass:
419
+ when active, a destructive verdict is downgraded to exit 0 with a
420
+ visible "policy bypassed" message so the operator can audit the
421
+ bypass after the fact.
422
+ """
423
+ if not command.strip():
424
+ return 2, (
425
+ "❌ deft destructive-gh-verb gate: empty command string passed.\n"
426
+ " Usage: preflight_gh.py --command \"<command>\""
427
+ )
428
+
429
+ verdict = classify_command(command, default_branches=default_branches)
430
+ if verdict.allowed:
431
+ return 0, (
432
+ f"✓ deft destructive-gh-verb gate: '{command}' is not "
433
+ "destructive -- proceeding."
434
+ )
435
+
436
+ if _env_bypass_active():
437
+ return 0, (
438
+ f"⚠ deft destructive-gh-verb gate: '{verdict.detail}' "
439
+ f"classified as {verdict.category}, but {ENV_BYPASS}=1 is "
440
+ "set -- policy bypassed for this session."
441
+ )
442
+
443
+ msg_lines = [
444
+ f"❌ deft destructive-gh-verb gate: refusing to execute "
445
+ f"({verdict.category}).",
446
+ "",
447
+ f" Detail: {verdict.detail}",
448
+ ]
449
+ if verdict.recovery:
450
+ msg_lines.extend(["", verdict.recovery])
451
+ return 1, "\n".join(msg_lines)
452
+
453
+
454
+ # ---------------------------------------------------------------------------
455
+ # Pre-push hook mode -- parse stdin per-ref lines
456
+ # ---------------------------------------------------------------------------
457
+
458
+
459
+ def _parse_pre_push_stdin(stream) -> list[tuple[str, str, str, str]]:
460
+ """Yield ``(local_ref, local_oid, remote_ref, remote_oid)`` tuples.
461
+
462
+ Tolerant of trailing whitespace and empty lines. Git's pre-push hook
463
+ feeds one such line per ref being pushed.
464
+ """
465
+ out: list[tuple[str, str, str, str]] = []
466
+ for raw in stream:
467
+ line = raw.strip()
468
+ if not line:
469
+ continue
470
+ parts = line.split()
471
+ if len(parts) != 4:
472
+ continue
473
+ out.append((parts[0], parts[1], parts[2], parts[3]))
474
+ return out
475
+
476
+
477
+ _ZERO_OID_RE = re.compile(r"^0+$")
478
+
479
+
480
+ def _is_zero_oid(oid: str) -> bool:
481
+ return bool(_ZERO_OID_RE.match(oid))
482
+
483
+
484
+ def evaluate_pre_push(
485
+ refs: list[tuple[str, str, str, str]],
486
+ *,
487
+ default_branches: frozenset[str] = DEFAULT_BRANCHES,
488
+ ) -> tuple[int, str]:
489
+ """Evaluate the per-ref data from git pre-push stdin.
490
+
491
+ Refuses any push that touches the default branch (creation or
492
+ update). Deletion of the default branch is independently destructive
493
+ and is also refused. Non-default branches are out of scope -- a
494
+ force-push to a feature branch is a normal rebase workflow.
495
+ """
496
+ if not refs:
497
+ return 0, (
498
+ "✓ deft destructive-gh-verb gate (pre-push): no refs in "
499
+ "stdin -- nothing to gate."
500
+ )
501
+
502
+ blocked: list[str] = []
503
+ for local_ref, local_oid, remote_ref, remote_oid in refs:
504
+ branch = remote_ref.removeprefix("refs/heads/")
505
+ if branch.lower() not in {b.lower() for b in default_branches}:
506
+ continue
507
+ # Touching the default branch from a hook context. The branch
508
+ # gate (#747) already refuses commits while on the default
509
+ # branch, but force-push from a feature branch targeting
510
+ # master is the case this gate exists to cover.
511
+ if _is_zero_oid(remote_oid):
512
+ blocked.append(f"create {branch} (local={local_ref})")
513
+ elif _is_zero_oid(local_oid):
514
+ # Deletion: local OID is zero (per-ref, not per-batch).
515
+ blocked.append(f"delete {branch}")
516
+ else:
517
+ blocked.append(f"update {branch} (local={local_ref})")
518
+
519
+ if not blocked:
520
+ return 0, (
521
+ "✓ deft destructive-gh-verb gate (pre-push): no pushes to "
522
+ "default branches detected -- proceeding."
523
+ )
524
+
525
+ if _env_bypass_active():
526
+ return 0, (
527
+ "⚠ deft destructive-gh-verb gate (pre-push): default-branch "
528
+ f"push detected ({'; '.join(blocked)}) but {ENV_BYPASS}=1 "
529
+ "is set -- policy bypassed for this session."
530
+ )
531
+
532
+ msg = (
533
+ "❌ deft destructive-gh-verb gate (pre-push): refusing to push "
534
+ "directly to the default branch.\n"
535
+ f" Detail: {'; '.join(blocked)}\n\n"
536
+ " How to proceed:\n"
537
+ " • push to a feature branch and open a PR\n"
538
+ f" • or set the env-var bypass for this shell: {ENV_BYPASS}=1\n"
539
+ " See scm/github.md (## Destructive gh verbs (#1019))."
540
+ )
541
+ return 1, msg
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # Self-test mode -- aggregated into ``task verify:destructive-gh-verbs``
546
+ # ---------------------------------------------------------------------------
547
+
548
+
549
+ #: Built-in fixture table. ``expected_category=None`` means "allowed";
550
+ #: anything else is a destructive category we expect the classifier to
551
+ #: flag. Mutating this table is the load-bearing contract -- any change
552
+ #: should be paired with a regression test in ``tests/cli/test_preflight_gh.py``.
553
+ _SELF_TEST_CASES: tuple[tuple[str, str | None], ...] = (
554
+ # delete_repo positives
555
+ ("gh repo delete deftai/directive", "delete_repo"),
556
+ ("gh repo delete deftai/directive --yes", "delete_repo"),
557
+ ("gh api -X DELETE repos/deftai/directive", "delete_repo"),
558
+ ("gh api --method DELETE repos/deftai/directive/contents/README.md", "delete_repo"),
559
+ ("gh api -XDELETE repos/deftai/directive", "delete_repo"),
560
+ # admin_merge positives
561
+ ("gh pr merge 123 --admin", "admin_merge"),
562
+ ("gh pr merge --admin --squash 123", "admin_merge"),
563
+ # force_push_default positives
564
+ ("git push --force origin master", "force_push_default"),
565
+ ("git push origin --force-with-lease main", "force_push_default"),
566
+ ("git push origin +master", "force_push_default"),
567
+ ("git push --force origin HEAD:master", "force_push_default"),
568
+ # Negatives -- benign commands MUST classify as allowed.
569
+ ("gh pr merge 123 --squash", None),
570
+ ("gh repo view deftai/directive", None),
571
+ ("gh api repos/deftai/directive", None),
572
+ ("gh api -X PATCH repos/deftai/directive/issues/1", None),
573
+ ("git push origin feat/my-branch", None),
574
+ ("git push --force origin feat/my-branch", None),
575
+ ("git push --force-with-lease origin feat/my-branch", None),
576
+ ("git push", None),
577
+ ("gh pr create --title Test --body foo", None),
578
+ )
579
+
580
+
581
+ def run_self_test() -> tuple[int, str]:
582
+ """Drive every fixture through the classifier; report disagreements.
583
+
584
+ Returns (exit_code, message). Exit code is 0 when every fixture
585
+ matches, 2 (config error) when any disagreement is found -- a
586
+ disagreement means the classifier table has drifted from the
587
+ contract and a real-world destructive verb might pass the gate.
588
+ """
589
+ failures: list[str] = []
590
+ for command, expected in _SELF_TEST_CASES:
591
+ verdict = classify_command(command)
592
+ observed = None if verdict.allowed else verdict.category
593
+ if observed != expected:
594
+ failures.append(
595
+ f" ✗ {command!r} -- expected category={expected!r} but "
596
+ f"got category={observed!r} (detail={verdict.detail!r})"
597
+ )
598
+
599
+ if failures:
600
+ return 2, (
601
+ "❌ deft destructive-gh-verb gate (self-test): classifier "
602
+ f"disagreement on {len(failures)}/{len(_SELF_TEST_CASES)} "
603
+ "fixture(s).\n" + "\n".join(failures)
604
+ )
605
+ return 0, (
606
+ f"✓ deft destructive-gh-verb gate (self-test): "
607
+ f"{len(_SELF_TEST_CASES)}/{len(_SELF_TEST_CASES)} fixtures "
608
+ "classified as expected."
609
+ )
610
+
611
+
612
+ # ---------------------------------------------------------------------------
613
+ # CLI
614
+ # ---------------------------------------------------------------------------
615
+
616
+
617
+ def _build_parser() -> argparse.ArgumentParser:
618
+ parser = argparse.ArgumentParser(
619
+ prog="preflight_gh.py",
620
+ description=(
621
+ "Detection-bound gate for destructive gh verbs (#1019). "
622
+ "Classifies a candidate command string as one of "
623
+ "delete_repo / admin_merge / force_push_default, or allowed."
624
+ ),
625
+ )
626
+ mode = parser.add_mutually_exclusive_group(required=False)
627
+ mode.add_argument(
628
+ "--command",
629
+ help="Classify a single candidate command string and exit.",
630
+ )
631
+ mode.add_argument(
632
+ "--pre-push-stdin",
633
+ action="store_true",
634
+ help=(
635
+ "Read git pre-push hook per-ref lines from stdin and refuse "
636
+ "pushes touching the default branch."
637
+ ),
638
+ )
639
+ mode.add_argument(
640
+ "--self-test",
641
+ action="store_true",
642
+ help=(
643
+ "Run the built-in fixture table through the classifier and "
644
+ "exit 0 / 2. Used by `task verify:destructive-gh-verbs`."
645
+ ),
646
+ )
647
+ parser.add_argument(
648
+ "--default-branch",
649
+ action="append",
650
+ default=None,
651
+ help=(
652
+ "Override the default-branch list. Pass multiple times to add. "
653
+ "Defaults to master + main."
654
+ ),
655
+ )
656
+ parser.add_argument(
657
+ "--quiet",
658
+ action="store_true",
659
+ help="Suppress the OK message (errors still print).",
660
+ )
661
+ parser.add_argument(
662
+ "--project-root",
663
+ default=".",
664
+ help=argparse.SUPPRESS, # accepted for parity with preflight_branch.py
665
+ )
666
+ return parser
667
+
668
+
669
+ def main(argv: list[str] | None = None, *, stdin=None) -> int:
670
+ # #814: Force UTF-8 stdout/stderr at hook-script entry. Mirrors
671
+ # ``scripts/preflight_branch.main`` -- see that module for the
672
+ # rationale (Windows git-hook invocations default to cp1252 which
673
+ # has no glyph for U+2713 / U+274C / U+26A0).
674
+ if hasattr(sys.stdout, "reconfigure"):
675
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
676
+ if hasattr(sys.stderr, "reconfigure"):
677
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
678
+
679
+ parser = _build_parser()
680
+ args = parser.parse_args(argv)
681
+ default_branches = (
682
+ frozenset(args.default_branch) if args.default_branch else DEFAULT_BRANCHES
683
+ )
684
+
685
+ if args.self_test:
686
+ code, msg = run_self_test()
687
+ elif args.pre_push_stdin:
688
+ refs = _parse_pre_push_stdin(stdin or sys.stdin)
689
+ code, msg = evaluate_pre_push(refs, default_branches=default_branches)
690
+ elif args.command is not None:
691
+ code, msg = evaluate_command(args.command, default_branches=default_branches)
692
+ else:
693
+ parser.error("one of --command / --pre-push-stdin / --self-test required")
694
+ return 2 # unreachable; argparse exits via SystemExit(2)
695
+
696
+ if code == 0:
697
+ if not args.quiet:
698
+ print(msg)
699
+ else:
700
+ print(msg, file=sys.stderr)
701
+ return code
702
+
703
+
704
+ # Public API surface -- the names the test module imports.
705
+ __all__ = [
706
+ "DEFAULT_BRANCHES",
707
+ "ENV_BYPASS",
708
+ "Verdict",
709
+ "classify_command",
710
+ "evaluate_command",
711
+ "evaluate_pre_push",
712
+ "main",
713
+ "run_self_test",
714
+ ]
715
+
716
+
717
+ if __name__ == "__main__":
718
+ # Pure path-mod for hook invocation: make ``scripts`` importable
719
+ # when this file is run directly (mirrors preflight_branch.py).
720
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
721
+ sys.exit(main())