@deftai/directive-content 0.58.0 → 0.60.0

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 (187) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +57 -67
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/rules/rules-pack-0.1.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +22 -22
  10. package/scm/github.md +20 -2
  11. package/tasks/change.yml +16 -31
  12. package/tasks/ci.yml +8 -0
  13. package/tasks/commit.yml +12 -19
  14. package/tasks/core.yml +10 -0
  15. package/tasks/engine.yml +42 -0
  16. package/tasks/framework.yml +3 -0
  17. package/tasks/install.yml +20 -19
  18. package/tasks/migrate.yml +26 -15
  19. package/tasks/project.yml +16 -0
  20. package/tasks/relocate.yml +18 -48
  21. package/tasks/toolchain.yml +15 -5
  22. package/tasks/vbrief.yml +4 -3
  23. package/tasks/verify.yml +12 -14
  24. package/templates/agents-entry.md +1 -2
  25. package/scripts/_agents_md.py +0 -494
  26. package/scripts/_cache_fetch.py +0 -635
  27. package/scripts/_cache_quota.py +0 -529
  28. package/scripts/_cache_refresh.py +0 -163
  29. package/scripts/_cache_validate.py +0 -209
  30. package/scripts/_content_root.py +0 -42
  31. package/scripts/_doctor_state.py +0 -277
  32. package/scripts/_event_detect.py +0 -305
  33. package/scripts/_events.py +0 -514
  34. package/scripts/_lifecycle_hygiene.py +0 -568
  35. package/scripts/_pathspec.py +0 -91
  36. package/scripts/_policy_show_cli.py +0 -266
  37. package/scripts/_precutover.py +0 -92
  38. package/scripts/_project_context.py +0 -224
  39. package/scripts/_project_definition_io.py +0 -164
  40. package/scripts/_relocate_snapshot.py +0 -209
  41. package/scripts/_relocate_states.py +0 -343
  42. package/scripts/_resolve_preflight_path.py +0 -152
  43. package/scripts/_safe_subprocess.py +0 -167
  44. package/scripts/_session_start_hook.py +0 -205
  45. package/scripts/_sor_gate_diff.py +0 -365
  46. package/scripts/_stdio_utf8.py +0 -59
  47. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  48. package/scripts/_triage_classify_cli.py +0 -122
  49. package/scripts/_triage_queue_cli.py +0 -625
  50. package/scripts/_triage_scope_cli.py +0 -343
  51. package/scripts/_triage_scope_drift_cli.py +0 -121
  52. package/scripts/_triage_scope_ignores.py +0 -286
  53. package/scripts/_triage_scope_milestone.py +0 -432
  54. package/scripts/_triage_scope_mutations.py +0 -337
  55. package/scripts/_triage_scope_renderers.py +0 -207
  56. package/scripts/_triage_smoketest_stages.py +0 -674
  57. package/scripts/_triage_subscribe_cli.py +0 -140
  58. package/scripts/_triage_welcome_cli.py +0 -421
  59. package/scripts/_vbrief_build.py +0 -239
  60. package/scripts/_vbrief_fidelity.py +0 -479
  61. package/scripts/_vbrief_legacy.py +0 -589
  62. package/scripts/_vbrief_reconciliation.py +0 -883
  63. package/scripts/_vbrief_routing.py +0 -277
  64. package/scripts/_vbrief_safety.py +0 -778
  65. package/scripts/_vbrief_sources.py +0 -312
  66. package/scripts/_vbrief_speckit.py +0 -262
  67. package/scripts/_vbrief_story_quality.py +0 -353
  68. package/scripts/_vbrief_validation.py +0 -299
  69. package/scripts/build_dist.py +0 -412
  70. package/scripts/cache.py +0 -1078
  71. package/scripts/cache_scanner.py +0 -745
  72. package/scripts/candidates_log.py +0 -432
  73. package/scripts/capacity_backfill.py +0 -680
  74. package/scripts/capacity_show.py +0 -653
  75. package/scripts/ci_local.py +0 -689
  76. package/scripts/code_structure_validate.py +0 -765
  77. package/scripts/codebase_default_extractor.py +0 -495
  78. package/scripts/codebase_map.py +0 -304
  79. package/scripts/codebase_map_fresh.py +0 -104
  80. package/scripts/codebase_projection_registry.py +0 -94
  81. package/scripts/codebase_provider.py +0 -582
  82. package/scripts/doctor.py +0 -2551
  83. package/scripts/framework_commands.py +0 -505
  84. package/scripts/gh_rest.py +0 -882
  85. package/scripts/github_auth_modes.py +0 -437
  86. package/scripts/github_body.py +0 -292
  87. package/scripts/ip_risk.py +0 -531
  88. package/scripts/issue_emit.py +0 -670
  89. package/scripts/issue_ingest.py +0 -1064
  90. package/scripts/migrate_preflight.py +0 -418
  91. package/scripts/migrate_vbrief.py +0 -2677
  92. package/scripts/monitor_pr.py +0 -401
  93. package/scripts/pack_migrate_lessons.py +0 -336
  94. package/scripts/pack_migrate_patterns.py +0 -254
  95. package/scripts/pack_migrate_rules.py +0 -350
  96. package/scripts/pack_migrate_skills.py +0 -423
  97. package/scripts/pack_migrate_strategies.py +0 -311
  98. package/scripts/pack_migrate_swarm_spec.py +0 -250
  99. package/scripts/pack_render.py +0 -434
  100. package/scripts/packs_slice.py +0 -712
  101. package/scripts/platform_capabilities.py +0 -336
  102. package/scripts/policy.py +0 -2826
  103. package/scripts/policy_set.py +0 -324
  104. package/scripts/pr_check_closing_keywords.py +0 -524
  105. package/scripts/pr_check_protected_issues.py +0 -267
  106. package/scripts/pr_merge_readiness.py +0 -1004
  107. package/scripts/pr_wait_mergeable.py +0 -669
  108. package/scripts/prd_render.py +0 -159
  109. package/scripts/preflight_architecture_sor.py +0 -974
  110. package/scripts/preflight_branch.py +0 -289
  111. package/scripts/preflight_cache.py +0 -974
  112. package/scripts/preflight_gh.py +0 -721
  113. package/scripts/preflight_implementation.py +0 -272
  114. package/scripts/preflight_story_start.py +0 -838
  115. package/scripts/preflight_wip_cap.py +0 -149
  116. package/scripts/probe_session.py +0 -545
  117. package/scripts/project_render.py +0 -293
  118. package/scripts/quarantine_ext.py +0 -237
  119. package/scripts/reconcile_issues.py +0 -1442
  120. package/scripts/refresh-path.ps1 +0 -107
  121. package/scripts/release.py +0 -2030
  122. package/scripts/release_e2e.py +0 -1011
  123. package/scripts/release_publish.py +0 -486
  124. package/scripts/release_rollback.py +0 -980
  125. package/scripts/relocate.py +0 -1034
  126. package/scripts/resolve_changelog_unreleased.py +0 -667
  127. package/scripts/resolve_version.py +0 -490
  128. package/scripts/resume_conditions.py +0 -706
  129. package/scripts/ritual_sentinel.py +0 -609
  130. package/scripts/roadmap_render.py +0 -635
  131. package/scripts/rule_ownership_lint.py +0 -325
  132. package/scripts/scm.py +0 -591
  133. package/scripts/scope_audit_log.py +0 -387
  134. package/scripts/scope_decompose.py +0 -654
  135. package/scripts/scope_demote.py +0 -509
  136. package/scripts/scope_lifecycle.py +0 -1126
  137. package/scripts/scope_undo.py +0 -772
  138. package/scripts/session_start.py +0 -406
  139. package/scripts/setup_ghx.py +0 -339
  140. package/scripts/setup_windows.ps1 +0 -220
  141. package/scripts/slice_audit.py +0 -585
  142. package/scripts/slice_record.py +0 -530
  143. package/scripts/slice_record_existing.py +0 -692
  144. package/scripts/slug_normalize.py +0 -178
  145. package/scripts/spec_render.py +0 -477
  146. package/scripts/spec_validate.py +0 -238
  147. package/scripts/subagent_monitor.py +0 -658
  148. package/scripts/swarm_complete_cohort.py +0 -644
  149. package/scripts/swarm_launch.py +0 -1206
  150. package/scripts/swarm_readiness.py +0 -554
  151. package/scripts/swarm_verify_review_clean.py +0 -438
  152. package/scripts/swarm_worktrees.py +0 -497
  153. package/scripts/toolchain-check.py +0 -52
  154. package/scripts/triage_actions.py +0 -871
  155. package/scripts/triage_bootstrap.py +0 -1153
  156. package/scripts/triage_bulk.py +0 -630
  157. package/scripts/triage_classify.py +0 -932
  158. package/scripts/triage_help.py +0 -1685
  159. package/scripts/triage_queue.py +0 -1944
  160. package/scripts/triage_reconcile.py +0 -581
  161. package/scripts/triage_refresh.py +0 -643
  162. package/scripts/triage_scope.py +0 -999
  163. package/scripts/triage_scope_drift.py +0 -575
  164. package/scripts/triage_smoketest.py +0 -396
  165. package/scripts/triage_subscribe.py +0 -399
  166. package/scripts/triage_summary.py +0 -1011
  167. package/scripts/triage_welcome.py +0 -1178
  168. package/scripts/ts_check_lane.py +0 -86
  169. package/scripts/validate-links.py +0 -64
  170. package/scripts/validate_strategy_output.py +0 -212
  171. package/scripts/vbrief_activate.py +0 -228
  172. package/scripts/vbrief_migrate_conformance.py +0 -368
  173. package/scripts/vbrief_reconcile_graph.py +0 -306
  174. package/scripts/vbrief_reconcile_labels.py +0 -460
  175. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  176. package/scripts/vbrief_validate.py +0 -1144
  177. package/scripts/verify-stubs.py +0 -61
  178. package/scripts/verify_capacity.py +0 -160
  179. package/scripts/verify_encoding.py +0 -699
  180. package/scripts/verify_hooks_installed.py +0 -206
  181. package/scripts/verify_investigation.py +0 -360
  182. package/scripts/verify_judgment_gates.py +0 -827
  183. package/scripts/verify_no_task_runtime.py +0 -171
  184. package/scripts/verify_scm_boundary.py +0 -509
  185. package/scripts/verify_session_ritual.py +0 -389
  186. package/scripts/verify_tools.py +0 -426
  187. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,721 +0,0 @@
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())