@deftai/directive-content 0.59.0 → 0.61.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 (190) hide show
  1. package/.githooks/pre-commit +10 -128
  2. package/.githooks/pre-push +8 -108
  3. package/Taskfile.yml +48 -58
  4. package/UPGRADING.md +19 -3
  5. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  6. package/docs/directive-lifecycle.md +73 -0
  7. package/docs/getting-started.md +5 -1
  8. package/package.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +1 -1
  10. package/packs/strategies/strategies-pack-0.1.json +19 -19
  11. package/scm/github.md +37 -6
  12. package/skills/deft-directive-setup/SKILL.md +24 -15
  13. package/strategies/speckit.md +14 -14
  14. package/strategies/v0-20-contract.md +12 -1
  15. package/tasks/change.yml +16 -31
  16. package/tasks/ci.yml +8 -0
  17. package/tasks/commit.yml +12 -19
  18. package/tasks/core.yml +10 -0
  19. package/tasks/engine.yml +42 -0
  20. package/tasks/framework.yml +3 -0
  21. package/tasks/install.yml +20 -19
  22. package/tasks/migrate.yml +26 -15
  23. package/tasks/project.yml +26 -0
  24. package/tasks/toolchain.yml +15 -5
  25. package/tasks/vbrief.yml +4 -3
  26. package/tasks/verify.yml +12 -14
  27. package/templates/agents-entry.md +1 -1
  28. package/scripts/_agents_md.py +0 -494
  29. package/scripts/_cache_fetch.py +0 -635
  30. package/scripts/_cache_quota.py +0 -529
  31. package/scripts/_cache_refresh.py +0 -163
  32. package/scripts/_cache_validate.py +0 -209
  33. package/scripts/_content_root.py +0 -42
  34. package/scripts/_doctor_state.py +0 -277
  35. package/scripts/_event_detect.py +0 -305
  36. package/scripts/_events.py +0 -514
  37. package/scripts/_lifecycle_hygiene.py +0 -568
  38. package/scripts/_pathspec.py +0 -91
  39. package/scripts/_policy_show_cli.py +0 -266
  40. package/scripts/_precutover.py +0 -92
  41. package/scripts/_project_context.py +0 -224
  42. package/scripts/_project_definition_io.py +0 -164
  43. package/scripts/_relocate_snapshot.py +0 -209
  44. package/scripts/_relocate_states.py +0 -343
  45. package/scripts/_resolve_preflight_path.py +0 -152
  46. package/scripts/_safe_subprocess.py +0 -167
  47. package/scripts/_session_start_hook.py +0 -205
  48. package/scripts/_sor_gate_diff.py +0 -365
  49. package/scripts/_stdio_utf8.py +0 -59
  50. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  51. package/scripts/_triage_classify_cli.py +0 -122
  52. package/scripts/_triage_queue_cli.py +0 -625
  53. package/scripts/_triage_scope_cli.py +0 -343
  54. package/scripts/_triage_scope_drift_cli.py +0 -121
  55. package/scripts/_triage_scope_ignores.py +0 -286
  56. package/scripts/_triage_scope_milestone.py +0 -432
  57. package/scripts/_triage_scope_mutations.py +0 -337
  58. package/scripts/_triage_scope_renderers.py +0 -207
  59. package/scripts/_triage_smoketest_stages.py +0 -674
  60. package/scripts/_triage_subscribe_cli.py +0 -140
  61. package/scripts/_triage_welcome_cli.py +0 -421
  62. package/scripts/_vbrief_build.py +0 -239
  63. package/scripts/_vbrief_fidelity.py +0 -479
  64. package/scripts/_vbrief_legacy.py +0 -589
  65. package/scripts/_vbrief_reconciliation.py +0 -883
  66. package/scripts/_vbrief_routing.py +0 -277
  67. package/scripts/_vbrief_safety.py +0 -778
  68. package/scripts/_vbrief_sources.py +0 -312
  69. package/scripts/_vbrief_speckit.py +0 -262
  70. package/scripts/_vbrief_story_quality.py +0 -353
  71. package/scripts/_vbrief_validation.py +0 -299
  72. package/scripts/build_dist.py +0 -412
  73. package/scripts/cache.py +0 -1078
  74. package/scripts/cache_scanner.py +0 -745
  75. package/scripts/candidates_log.py +0 -432
  76. package/scripts/capacity_backfill.py +0 -680
  77. package/scripts/capacity_show.py +0 -653
  78. package/scripts/ci_local.py +0 -689
  79. package/scripts/code_structure_validate.py +0 -765
  80. package/scripts/codebase_default_extractor.py +0 -495
  81. package/scripts/codebase_map.py +0 -304
  82. package/scripts/codebase_map_fresh.py +0 -104
  83. package/scripts/codebase_projection_registry.py +0 -94
  84. package/scripts/codebase_provider.py +0 -582
  85. package/scripts/doctor.py +0 -2552
  86. package/scripts/framework_commands.py +0 -505
  87. package/scripts/gh_rest.py +0 -882
  88. package/scripts/github_auth_modes.py +0 -437
  89. package/scripts/github_body.py +0 -292
  90. package/scripts/ip_risk.py +0 -531
  91. package/scripts/issue_emit.py +0 -670
  92. package/scripts/issue_ingest.py +0 -1064
  93. package/scripts/migrate_preflight.py +0 -418
  94. package/scripts/migrate_vbrief.py +0 -2677
  95. package/scripts/monitor_pr.py +0 -401
  96. package/scripts/pack_migrate_lessons.py +0 -336
  97. package/scripts/pack_migrate_patterns.py +0 -254
  98. package/scripts/pack_migrate_rules.py +0 -350
  99. package/scripts/pack_migrate_skills.py +0 -423
  100. package/scripts/pack_migrate_strategies.py +0 -311
  101. package/scripts/pack_migrate_swarm_spec.py +0 -250
  102. package/scripts/pack_render.py +0 -434
  103. package/scripts/packs_slice.py +0 -712
  104. package/scripts/platform_capabilities.py +0 -336
  105. package/scripts/policy.py +0 -2826
  106. package/scripts/policy_set.py +0 -324
  107. package/scripts/pr_check_closing_keywords.py +0 -524
  108. package/scripts/pr_check_protected_issues.py +0 -267
  109. package/scripts/pr_merge_readiness.py +0 -1004
  110. package/scripts/pr_wait_mergeable.py +0 -669
  111. package/scripts/prd_render.py +0 -159
  112. package/scripts/preflight_architecture_sor.py +0 -974
  113. package/scripts/preflight_branch.py +0 -289
  114. package/scripts/preflight_cache.py +0 -974
  115. package/scripts/preflight_gh.py +0 -721
  116. package/scripts/preflight_implementation.py +0 -272
  117. package/scripts/preflight_story_start.py +0 -838
  118. package/scripts/preflight_wip_cap.py +0 -149
  119. package/scripts/probe_session.py +0 -545
  120. package/scripts/project_render.py +0 -293
  121. package/scripts/quarantine_ext.py +0 -237
  122. package/scripts/reconcile_issues.py +0 -1442
  123. package/scripts/refresh-path.ps1 +0 -107
  124. package/scripts/release.py +0 -2030
  125. package/scripts/release_e2e.py +0 -1011
  126. package/scripts/release_publish.py +0 -486
  127. package/scripts/release_rollback.py +0 -980
  128. package/scripts/relocate.py +0 -1034
  129. package/scripts/resolve_changelog_unreleased.py +0 -667
  130. package/scripts/resolve_version.py +0 -490
  131. package/scripts/resume_conditions.py +0 -706
  132. package/scripts/ritual_sentinel.py +0 -609
  133. package/scripts/roadmap_render.py +0 -635
  134. package/scripts/rule_ownership_lint.py +0 -325
  135. package/scripts/scm.py +0 -591
  136. package/scripts/scope_audit_log.py +0 -387
  137. package/scripts/scope_decompose.py +0 -654
  138. package/scripts/scope_demote.py +0 -509
  139. package/scripts/scope_lifecycle.py +0 -1126
  140. package/scripts/scope_undo.py +0 -772
  141. package/scripts/session_start.py +0 -406
  142. package/scripts/setup_ghx.py +0 -339
  143. package/scripts/setup_windows.ps1 +0 -220
  144. package/scripts/slice_audit.py +0 -585
  145. package/scripts/slice_record.py +0 -530
  146. package/scripts/slice_record_existing.py +0 -692
  147. package/scripts/slug_normalize.py +0 -178
  148. package/scripts/spec_render.py +0 -477
  149. package/scripts/spec_validate.py +0 -238
  150. package/scripts/subagent_monitor.py +0 -658
  151. package/scripts/swarm_complete_cohort.py +0 -644
  152. package/scripts/swarm_launch.py +0 -1206
  153. package/scripts/swarm_readiness.py +0 -554
  154. package/scripts/swarm_verify_review_clean.py +0 -438
  155. package/scripts/swarm_worktrees.py +0 -497
  156. package/scripts/toolchain-check.py +0 -52
  157. package/scripts/triage_actions.py +0 -871
  158. package/scripts/triage_bootstrap.py +0 -1153
  159. package/scripts/triage_bulk.py +0 -630
  160. package/scripts/triage_classify.py +0 -932
  161. package/scripts/triage_help.py +0 -1685
  162. package/scripts/triage_queue.py +0 -1944
  163. package/scripts/triage_reconcile.py +0 -581
  164. package/scripts/triage_refresh.py +0 -643
  165. package/scripts/triage_scope.py +0 -999
  166. package/scripts/triage_scope_drift.py +0 -575
  167. package/scripts/triage_smoketest.py +0 -396
  168. package/scripts/triage_subscribe.py +0 -399
  169. package/scripts/triage_summary.py +0 -1011
  170. package/scripts/triage_welcome.py +0 -1178
  171. package/scripts/ts_check_lane.py +0 -86
  172. package/scripts/validate-links.py +0 -64
  173. package/scripts/validate_strategy_output.py +0 -212
  174. package/scripts/vbrief_activate.py +0 -228
  175. package/scripts/vbrief_migrate_conformance.py +0 -368
  176. package/scripts/vbrief_reconcile_graph.py +0 -306
  177. package/scripts/vbrief_reconcile_labels.py +0 -460
  178. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  179. package/scripts/vbrief_validate.py +0 -1144
  180. package/scripts/verify-stubs.py +0 -61
  181. package/scripts/verify_capacity.py +0 -160
  182. package/scripts/verify_encoding.py +0 -699
  183. package/scripts/verify_hooks_installed.py +0 -206
  184. package/scripts/verify_investigation.py +0 -360
  185. package/scripts/verify_judgment_gates.py +0 -827
  186. package/scripts/verify_no_task_runtime.py +0 -171
  187. package/scripts/verify_scm_boundary.py +0 -509
  188. package/scripts/verify_session_ritual.py +0 -389
  189. package/scripts/verify_tools.py +0 -426
  190. package/scripts/verify_vbrief_conformance.py +0 -478
package/scripts/scm.py DELETED
@@ -1,591 +0,0 @@
1
- #!/usr/bin/env python3
2
- """scripts/scm.py -- minimal scm:* stub wrapper for #883 v1 cache layer (Story 1).
3
-
4
- DO NOT EXTEND. The full scm:* namespace lives at #881; this script is replaced
5
- wholesale when #881 lands. The stub exposes only the four ``issue:*`` commands
6
- the v1 cache consumer (Story 2 ``cache:fetch-all``) needs:
7
-
8
- scm.py issue list <pass-through args>
9
- scm.py issue view <pass-through args>
10
- scm.py issue close <pass-through args>
11
- scm.py issue edit <pass-through args>
12
-
13
- Each command is a thin pass-through to ``ghx <namespace> <verb> ...`` when
14
- ``ghx`` is on PATH, falling back to ``gh <namespace> <verb> ...`` otherwise.
15
- This mirrors the #884 ``ghx-as-standard-gh-proxy`` recommendation while
16
- keeping the stub functional on machines where only ``gh`` is installed.
17
-
18
- The JSON-shape contract Story 2 consumes is pinned independently by the
19
- ``tests/test_scm_contract.py`` contract test against
20
- ``tests/fixtures/scm_issue_view.json`` -- this script does NOT validate or
21
- transform the JSON; it forwards stdout/stderr/exit-code from the underlying
22
- binary verbatim.
23
-
24
- REST opt-in mode (#976)
25
- -----------------------
26
- A new ``--rest`` flag is recognised on ``issue view`` and ``issue list``
27
- invocations. When present, the stub routes the read through the REST
28
- helpers in :mod:`scripts.gh_rest` (``rest_issue_view`` /
29
- ``rest_issue_list``) instead of forwarding ``gh issue view|list`` to the
30
- underlying binary. This sidesteps the GraphQL bucket entirely so a
31
- depleted ``graphql.remaining`` (a recurring failure mode -- see #976,
32
- #961, #884) no longer fails read-only smoke / cache flows.
33
-
34
- The REST shape differs from the gh ``--json`` GraphQL shape (e.g. REST
35
- emits ``user`` not ``author``, ``created_at`` not ``createdAt``, lower-
36
- case ``state``). Story 2 ``cache:fetch-all`` continues to consume the
37
- legacy GraphQL shape via the default code path; only callers that
38
- opt in via ``--rest`` see the REST shape. The smoke test
39
- (``tests/integration/test_scm_smoke.py``) and any other non-cache
40
- reader can opt in safely. Mutations (``close``, ``edit``) still
41
- forward to ``gh`` -- they have non-trivial flag surfaces (--body-file,
42
- --add-label, --remove-label, ...) that this stub deliberately does not
43
- re-implement; #881 owns the full surface.
44
-
45
- GraphQL-only operations (cannot be REST-migrated)
46
- --------------------------------------------------
47
- GitHub exposes two PR-state mutations only via GraphQL; they have NO
48
- REST equivalent and remain budgeted GraphQL spend wherever they appear:
49
-
50
- - ``markPullRequestReadyForReview`` (``gh pr ready``). Used by the
51
- release/PR flow; documented in
52
- :mod:`scripts.gh_rest` module docstring known limitations.
53
- - ``addPullRequestReview`` (``gh pr review --approve|--request-changes``).
54
- Required for formal review verdicts; ``rest_post_comment`` is the
55
- REST-budget alternative when no approval semantics are needed.
56
-
57
- Future agents touching this stub: those two surfaces are accidental-drain-free
58
- and legitimate GraphQL spend. Every OTHER GraphQL-backed gh invocation in
59
- this stub's surface is candidate for REST migration -- the ``--rest`` flag
60
- is the v1 wedge.
61
- """
62
-
63
- from __future__ import annotations
64
-
65
- import importlib
66
- import json
67
- import shutil
68
- import subprocess
69
- import sys
70
- from collections.abc import Sequence
71
- from typing import Any
72
-
73
- # ---------------------------------------------------------------------------
74
- # Constants
75
- # ---------------------------------------------------------------------------
76
-
77
- #: Allowed ``<namespace>`` argv[1] -- the v1 stub only exposes ``issue``.
78
- #: PR commands (#881 future) and any other namespace are rejected loudly so
79
- #: a typo doesn't silently dispatch unexpected gh subcommands.
80
- _ALLOWED_NAMESPACES: tuple[str, ...] = ("issue",)
81
-
82
- #: Source-aware shim (#1145 / N5) supported sources. v1 ships with only
83
- #: ``github-issue``; ``gitlab`` / ``gitea`` / ``local`` are placeholders for
84
- #: #445 / #935 Workstream 6 and raise :class:`NotImplementedError` with the
85
- #: canonical message documented on issue #1145. Adding a source here without
86
- #: a matching backend implementation in :func:`call` is a bug -- the verifier
87
- #: :mod:`scripts.verify_scm_boundary` does not enforce backend coverage, but
88
- #: the unit test ``tests/test_scm_call.py::test_unknown_source_raises`` pins
89
- #: the exhaustive-source contract.
90
- _SUPPORTED_CALL_SOURCES: tuple[str, ...] = ("github-issue",)
91
-
92
- #: Allowed ``<verb>`` argv[2] for the ``issue`` namespace. Mirrors the four
93
- #: AC-1 commands in vbrief/active/2026-05-05-883-story-1-scm-stub.vbrief.json.
94
- _ALLOWED_ISSUE_VERBS: tuple[str, ...] = ("list", "view", "close", "edit")
95
-
96
- #: Binary preference order. ``ghx`` is the #884 standard proxy; ``gh`` is the
97
- #: canonical fallback. Tests parametrise this via subprocess + shutil.which
98
- #: mocks so the fallback path is exercised independent of the host PATH.
99
- _BINARY_PREFERENCE: tuple[str, ...] = ("ghx", "gh")
100
-
101
-
102
- class ScmStubError(RuntimeError):
103
- """Raised on argv-validation or binary-resolution failures."""
104
-
105
-
106
- # ---------------------------------------------------------------------------
107
- # Resolution
108
- # ---------------------------------------------------------------------------
109
-
110
-
111
- def resolve_binary() -> str:
112
- """Return ``"ghx"`` if on PATH, else ``"gh"``; raise if neither is present.
113
-
114
- The fallback order is fixed by :data:`_BINARY_PREFERENCE` so a regression
115
- that re-orders or shadows a binary fails the unit test in
116
- ``tests/test_scm_stub.py`` rather than silently dispatching to the wrong
117
- proxy. Both binaries accept identical ``issue list/view/close/edit``
118
- surfaces for the v1 stub's purposes.
119
- """
120
- for candidate in _BINARY_PREFERENCE:
121
- if shutil.which(candidate) is not None:
122
- return candidate
123
- raise ScmStubError(
124
- "neither 'ghx' nor 'gh' found on PATH; install GitHub CLI "
125
- "(https://cli.github.com/) or the ghx proxy (#884)"
126
- )
127
-
128
-
129
- # ---------------------------------------------------------------------------
130
- # Argv shaping
131
- # ---------------------------------------------------------------------------
132
-
133
-
134
- def build_command(
135
- namespace: str, verb: str, extra: Sequence[str], *, binary: str | None = None
136
- ) -> list[str]:
137
- """Construct the underlying ``[binary, namespace, verb, *extra]`` argv.
138
-
139
- Args:
140
- namespace: One of :data:`_ALLOWED_NAMESPACES`. Anything else raises
141
- :class:`ScmStubError` -- the stub deliberately refuses unknown
142
- namespaces so a typo (``isue``) doesn't get forwarded to gh and
143
- produce a confusing native-error message.
144
- verb: For ``issue``, one of :data:`_ALLOWED_ISSUE_VERBS`. Same loud-
145
- failure rationale as namespace validation.
146
- extra: Pass-through positional / option args. Forwarded verbatim;
147
- this stub does NOT inspect or rewrite them.
148
- binary: Optional override for the resolved binary. When ``None``,
149
- :func:`resolve_binary` is consulted. Tests pass an explicit
150
- value so they don't depend on the host PATH.
151
-
152
- Returns:
153
- The argv list ready for :func:`subprocess.run`.
154
- """
155
- if namespace not in _ALLOWED_NAMESPACES:
156
- raise ScmStubError(
157
- f"unknown scm namespace {namespace!r}; expected one of "
158
- f"{_ALLOWED_NAMESPACES}. The full scm:* namespace lives at #881."
159
- )
160
- if namespace == "issue" and verb not in _ALLOWED_ISSUE_VERBS:
161
- raise ScmStubError(
162
- f"unknown scm:issue verb {verb!r}; expected one of "
163
- f"{_ALLOWED_ISSUE_VERBS}. The v1 stub only exposes these four; "
164
- "additional scm:issue:* commands belong on #881."
165
- )
166
- resolved = binary if binary is not None else resolve_binary()
167
- return [resolved, namespace, verb, *extra]
168
-
169
-
170
- # ---------------------------------------------------------------------------
171
- # Source-aware call shim (#1145 / N5)
172
- # ---------------------------------------------------------------------------
173
-
174
-
175
- def call(
176
- source: str,
177
- verb: str,
178
- args: Sequence[str] | None = None,
179
- *,
180
- check: bool = False,
181
- capture_output: bool = True,
182
- text: bool = True,
183
- timeout: float | None = None,
184
- cwd: str | None = None,
185
- binary: str | None = None,
186
- **kwargs: Any,
187
- ) -> subprocess.CompletedProcess[str]:
188
- """Source-aware SCM invocation -- partial down-payment on #445 / #935 Workstream 6.
189
-
190
- This is the single seam through which the deft framework's verb layer
191
- (``scripts/triage_*.py``, ``scripts/scope_*.py``, ``scripts/slice_*.py``,
192
- ``scripts/issue_ingest.py``, ...) invokes the underlying SCM CLI.
193
- Pre-N5, every consumer called ``subprocess.run(["gh", ...])`` directly;
194
- the first non-GitHub consumer would have hit an undocumented coupling
195
- deep in the call stack. The shim relocates that coupling to one
196
- indirection point so the full SCM abstraction (#445 / #935 Workstream 6)
197
- has a single seam to extend.
198
-
199
- Routing (v1):
200
-
201
- - ``source="github-issue"`` -- forwards to ``[binary, verb, *args]``
202
- where ``binary`` comes from :func:`resolve_binary` (the #884
203
- ``ghx`` -> ``gh`` preference ladder). This is the only source v1
204
- implements.
205
- - Any other source (``"gitlab"``, ``"gitea"``, ``"local"``, ...)
206
- raises :class:`NotImplementedError` with the canonical message
207
- ``"source=<x> not yet supported; see #445 / #935 Workstream 6 for
208
- the abstraction."`` so a consumer on a non-GitHub forge sees the
209
- deferred abstraction immediately instead of an obscure
210
- ``"gh: command not found"`` deep in the call stack.
211
-
212
- Args:
213
- source: Forge identity for the invocation. Currently only
214
- ``"github-issue"`` is implemented; see
215
- :data:`_SUPPORTED_CALL_SOURCES` for the contract.
216
- verb: The CLI verb passed to the resolved binary (e.g. ``"issue"``,
217
- ``"api"``, ``"pr"``). Forwarded verbatim as the first argv
218
- element after the binary; this shim deliberately does NOT
219
- validate the verb so callers can use any surface the
220
- underlying binary supports.
221
- args: Pass-through positional / option args appended after
222
- ``verb``. Defaults to an empty sequence.
223
- check: Forwarded to :func:`subprocess.run`. Defaults to ``False``
224
- so callers can inspect non-zero exits without an exception;
225
- mutation call sites that want loud failures opt in via
226
- ``check=True``.
227
- capture_output / text: Forwarded to :func:`subprocess.run`.
228
- Defaults to capture+text so the common "parse stdout" usage
229
- works without extra plumbing.
230
- timeout: Optional wall-clock cap forwarded to
231
- :func:`subprocess.run`. Mirrors the existing per-call-site
232
- timeouts (e.g. ``issue_ingest._fetch_single_issue`` uses
233
- 30s).
234
- cwd: Optional working directory forwarded to
235
- :func:`subprocess.run`.
236
- binary: Optional override for the resolved binary. Tests pass
237
- this so they don't depend on the host PATH.
238
- **kwargs: Additional :func:`subprocess.run` keyword args
239
- (``env``, ``input``, ``stdin``, ...).
240
-
241
- Returns:
242
- The :class:`subprocess.CompletedProcess` from the underlying
243
- invocation -- the shim does not parse or transform stdout /
244
- stderr / returncode.
245
-
246
- Raises:
247
- NotImplementedError: When ``source`` is not in
248
- :data:`_SUPPORTED_CALL_SOURCES`. The error message points at
249
- #445 / #935 Workstream 6 so consumers on GitLab / Gitea /
250
- local backends see the deferred abstraction immediately.
251
- ScmStubError: When neither ``gh`` nor ``ghx`` is on PATH and no
252
- explicit ``binary`` override was provided.
253
-
254
- Notes on the verifier (`scripts/verify_scm_boundary.py`):
255
- The companion deterministic gate scans tracked Python files in
256
- the verb-layer scope (``scripts/triage_*.py``,
257
- ``scripts/scope_*.py``, ``scripts/slice_*.py``,
258
- ``scripts/_triage_*.py``, ``scripts/_scope_*.py``,
259
- ``scripts/resume_conditions.py``, ``scripts/issue_ingest.py``)
260
- for subprocess / Popen / os.system invocations whose first
261
- argv element is the literal ``"gh"`` or ``"ghx"``. Any such
262
- call in those files is a violation because it bypasses this
263
- shim. The verifier deliberately scopes by file glob rather
264
- than scanning every tracked Python file so release tooling,
265
- REST helpers (:mod:`scripts.gh_rest`), and the ghx installer
266
- (:mod:`scripts.setup_ghx`) -- which have legitimate direct-gh
267
- responsibilities -- are not flagged.
268
- """
269
- if source not in _SUPPORTED_CALL_SOURCES:
270
- raise NotImplementedError(
271
- f"source={source!r} not yet supported; "
272
- "see #445 / #935 Workstream 6 for the abstraction."
273
- )
274
- resolved = binary if binary is not None else resolve_binary()
275
- argv = [resolved, verb, *(args if args is not None else ())]
276
- return subprocess.run(
277
- argv,
278
- check=check,
279
- capture_output=capture_output,
280
- text=text,
281
- timeout=timeout,
282
- cwd=cwd,
283
- **kwargs,
284
- )
285
-
286
-
287
- # ---------------------------------------------------------------------------
288
- # REST opt-in (#976)
289
- # ---------------------------------------------------------------------------
290
-
291
- #: Verbs that support the ``--rest`` opt-in. Only read paths -- mutations
292
- #: (close, edit) keep forwarding to gh in the v1 stub.
293
- _REST_OPT_IN_VERBS: tuple[str, ...] = ("view", "list")
294
-
295
-
296
- def _extract_flag(extra: list[str], flag: str) -> tuple[bool, list[str]]:
297
- """Return ``(present, remainder)`` after removing every occurrence of ``flag``.
298
-
299
- Used to peel off the ``--rest`` opt-in flag before the remaining argv
300
- is consumed by the REST dispatcher (or, in the legacy path, forwarded
301
- to ``ghx|gh``).
302
- """
303
- present = flag in extra
304
- remainder = [a for a in extra if a != flag]
305
- return present, remainder
306
-
307
-
308
- def _extract_value_flag(
309
- extra: list[str], flag: str, default: str | None = None
310
- ) -> tuple[str | None, list[str]]:
311
- """Return ``(value, remainder)`` for ``--flag VALUE`` or ``--flag=VALUE``.
312
-
313
- Removes the consumed tokens from ``extra``. Mirrors the small subset of
314
- argv parsing the stub does so it can extract ``--repo`` / ``--json`` /
315
- ``--state`` etc. from the pass-through args without pulling in argparse.
316
- The first occurrence wins; a missing flag returns ``default``.
317
- """
318
- out: list[str] = []
319
- value = default
320
- seen = False
321
- i = 0
322
- while i < len(extra):
323
- token = extra[i]
324
- if not seen and token == flag and i + 1 < len(extra):
325
- value = extra[i + 1]
326
- seen = True
327
- i += 2
328
- continue
329
- if not seen and token.startswith(flag + "="):
330
- value = token.split("=", 1)[1]
331
- seen = True
332
- i += 1
333
- continue
334
- out.append(token)
335
- i += 1
336
- return value, out
337
-
338
-
339
- def _filter_json_fields(obj: Any, fields: Sequence[str]) -> Any:
340
- """Project ``obj`` (dict or list[dict]) onto ``fields``.
341
-
342
- Mirrors gh's ``--json field1,field2`` semantics for the REST shape:
343
- only the named keys survive. Unknown fields are silently dropped
344
- rather than raised, matching gh's own behaviour. Empty ``fields``
345
- returns ``obj`` unchanged so callers that omit ``--json`` get the
346
- full REST response.
347
- """
348
- if not fields:
349
- return obj
350
- field_set = list(fields)
351
- if isinstance(obj, list):
352
- return [_filter_json_fields(item, field_set) for item in obj]
353
- if isinstance(obj, dict):
354
- return {k: obj[k] for k in field_set if k in obj}
355
- return obj
356
-
357
-
358
- def _run_rest_view(extra: list[str]) -> int:
359
- """Dispatch ``scm.py issue view --rest <N> --repo X [--json fields]``.
360
-
361
- Routes through :func:`scripts.gh_rest.rest_issue_view` so the read
362
- never touches GraphQL. Emits the REST response (filtered to
363
- ``--json`` fields if provided) to stdout as JSON, mirroring the
364
- legacy gh stdout contract callers consume.
365
-
366
- Unknown flags (anything beginning with ``-`` after stripping the
367
- consumed ``--repo`` / ``--json``) are rejected loudly so an
368
- operator-side typo (e.g. ``--state closed`` accidentally passed to
369
- ``issue view``) surfaces immediately rather than being silently
370
- ignored. Greptile P2 (#976 review): the prior implementation kept
371
- these tokens in ``extra`` after extraction and never inspected
372
- them again; the user got an unrelated successful response.
373
- """
374
- repo, extra = _extract_value_flag(extra, "--repo")
375
- json_spec, extra = _extract_value_flag(extra, "--json")
376
- if not repo:
377
- print("error: --rest issue view requires --repo OWNER/NAME", file=sys.stderr)
378
- return 2
379
- # The remaining positional arg (after stripping --repo/--json) is the
380
- # issue number. Reject extra unknown flags loudly so a typo is caught.
381
- positionals = [t for t in extra if not t.startswith("-")]
382
- leftover_flags = [t for t in extra if t.startswith("-")]
383
- if leftover_flags:
384
- print(
385
- f"error: --rest issue view does not recognise these flags: "
386
- f"{leftover_flags!r}. Supported flags are --repo, --json. "
387
- "Mutations / additional read filters belong on #881.",
388
- file=sys.stderr,
389
- )
390
- return 2
391
- if len(positionals) != 1:
392
- print(
393
- "error: --rest issue view expects exactly one positional issue "
394
- f"number; got {positionals!r}",
395
- file=sys.stderr,
396
- )
397
- return 2
398
- try:
399
- issue_n = int(positionals[0])
400
- except ValueError:
401
- print(
402
- f"error: issue number must be an integer; got {positionals[0]!r}",
403
- file=sys.stderr,
404
- )
405
- return 2
406
- gh_rest = importlib.import_module("gh_rest")
407
- try:
408
- response = gh_rest.rest_issue_view(repo, issue_n)
409
- except gh_rest.InvalidRepoError as exc:
410
- # InvalidRepoError is a ValueError subclass raised by
411
- # gh_rest._split_repo when --repo lacks the OWNER/NAME shape
412
- # (e.g. ``--repo directive`` instead of ``--repo deftai/directive``).
413
- # Treat it as an arg-validation failure (exit 2) so the user
414
- # sees a clean error rather than an uncaught traceback.
415
- # Greptile P1 #998 review at 367748e.
416
- print(f"error: invalid --repo value: {exc}", file=sys.stderr)
417
- return 2
418
- except gh_rest.GhRestError as exc:
419
- print(f"error: {exc}", file=sys.stderr)
420
- return 1
421
- fields = [f.strip() for f in json_spec.split(",")] if json_spec else []
422
- filtered = _filter_json_fields(response, fields)
423
- print(json.dumps(filtered, ensure_ascii=False))
424
- return 0
425
-
426
-
427
- def _run_rest_list(extra: list[str]) -> int:
428
- """Dispatch ``scm.py issue list --rest --repo X [...flags]``.
429
-
430
- Supported flags: ``--state {open|closed|all}`` (default open),
431
- ``--label NAME[,NAME...]`` (comma-separated label filter; multi-flag
432
- repetition `--label A --label B` is also accepted and merged into
433
- the same filter set), ``--author LOGIN`` (#1055 -- filter by issue
434
- creator; maps to the REST ``creator`` param and composes with
435
- ``--label`` via AND), ``--limit N`` (REST per_page, default 30),
436
- ``--json field1,field2`` (project the response onto the named keys,
437
- list-aware).
438
-
439
- Unknown flags after stripping the consumed flag set are rejected
440
- loudly (Greptile P2 #976 review): the prior implementation silently
441
- dropped any leftover ``--foo`` token, which produced subtly wrong
442
- behaviour (e.g. a typo'd ``--label-name`` was ignored without error).
443
-
444
- The list verb takes NO positional arguments; any leftover positional
445
- token is rejected loudly so a caller who typo'd `issue list --rest 123
446
- --repo o/r` (meaning `issue view`) sees the mistake immediately
447
- instead of receiving the full open-issues collection silently
448
- (Greptile P1 #976 second-pass review). Mirrors the parallel guard
449
- in ``_run_rest_view`` for symmetry.
450
-
451
- Routes through :func:`scripts.gh_rest.rest_issue_list`.
452
- """
453
- repo, extra = _extract_value_flag(extra, "--repo")
454
- state, extra = _extract_value_flag(extra, "--state", default="open")
455
- json_spec, extra = _extract_value_flag(extra, "--json")
456
- author, extra = _extract_value_flag(extra, "--author")
457
- # --label may appear multiple times; collect all occurrences and
458
- # merge with comma-separated values from any single occurrence.
459
- label_values: list[str] = []
460
- while True:
461
- label_part, extra = _extract_value_flag(extra, "--label")
462
- if label_part is None:
463
- break
464
- label_values.append(label_part)
465
- limit_str, extra = _extract_value_flag(extra, "--limit", default="30")
466
- leftover_flags = [t for t in extra if t.startswith("-")]
467
- if leftover_flags:
468
- print(
469
- f"error: --rest issue list does not recognise these flags: "
470
- f"{leftover_flags!r}. Supported flags are --repo, --state, "
471
- "--label, --author, --limit, --json. Additional filters "
472
- "belong on #881.",
473
- file=sys.stderr,
474
- )
475
- return 2
476
- leftover_positionals = [t for t in extra if not t.startswith("-")]
477
- if leftover_positionals:
478
- print(
479
- f"error: --rest issue list takes no positional arguments; "
480
- f"got {leftover_positionals!r}. Did you mean "
481
- f"`scm.py issue view --rest {leftover_positionals[0]} --repo OWNER/NAME`?",
482
- file=sys.stderr,
483
- )
484
- return 2
485
- if not repo:
486
- print("error: --rest issue list requires --repo OWNER/NAME", file=sys.stderr)
487
- return 2
488
- try:
489
- per_page = int(limit_str) if limit_str is not None else 30
490
- except ValueError:
491
- print(
492
- f"error: --limit must be an integer; got {limit_str!r}",
493
- file=sys.stderr,
494
- )
495
- return 2
496
- labels: tuple[str, ...] = tuple(
497
- item.strip()
498
- for value in label_values
499
- for item in value.split(",")
500
- if item.strip()
501
- )
502
- gh_rest = importlib.import_module("gh_rest")
503
- assert state is not None # default ensures non-None
504
- try:
505
- response = gh_rest.rest_issue_list(
506
- repo, state=state, labels=labels, author=author, per_page=per_page
507
- )
508
- except gh_rest.InvalidRepoError as exc:
509
- # See _run_rest_view for rationale; same gap (Greptile P1 #998
510
- # review at 367748e) -- _split_repo validation must surface as
511
- # exit 2 with a clean message, not an uncaught traceback.
512
- print(f"error: invalid --repo value: {exc}", file=sys.stderr)
513
- return 2
514
- except gh_rest.GhRestError as exc:
515
- print(f"error: {exc}", file=sys.stderr)
516
- return 1
517
- fields = [f.strip() for f in json_spec.split(",")] if json_spec else []
518
- filtered = _filter_json_fields(response, fields)
519
- print(json.dumps(filtered, ensure_ascii=False))
520
- return 0
521
-
522
-
523
- # ---------------------------------------------------------------------------
524
- # Entry point
525
- # ---------------------------------------------------------------------------
526
-
527
-
528
- def main(argv: list[str] | None = None) -> int:
529
- """CLI entry point. Returns the underlying binary's exit code (or 2 on arg error).
530
-
531
- Argv layout:
532
- argv[0] = namespace (only ``issue`` in the v1 stub)
533
- argv[1] = verb (one of list/view/close/edit)
534
- argv[2:] = pass-through args forwarded to ``ghx|gh`` (legacy path)
535
- OR consumed by the REST dispatcher when ``--rest`` is
536
- present (#976).
537
-
538
- No argparse: the stub deliberately avoids capturing ``--help`` / ``--json``
539
- / etc. flags itself in the legacy path, so they reach the underlying
540
- binary untouched. The only argv inspection the stub performs is:
541
-
542
- 1. The namespace + verb whitelist in :func:`build_command` (which fails
543
- loud rather than dispatching unknown surfaces).
544
- 2. The ``--rest`` opt-in extraction (#976) -- when present on a
545
- supported read verb (``view``, ``list``), routes the read through
546
- :mod:`scripts.gh_rest` REST helpers instead of forwarding to
547
- ``ghx|gh``. The flag is stripped before any forwarding so the
548
- legacy path is unaffected on calls that don't opt in.
549
- """
550
- args = list(sys.argv[1:] if argv is None else argv)
551
- if len(args) < 2:
552
- print(
553
- "usage: scm.py <namespace> <verb> [pass-through args...]\n"
554
- " (v1 stub: namespace=issue, verb=list|view|close|edit)\n"
555
- " --rest opt-in is supported on issue view/list (#976)",
556
- file=sys.stderr,
557
- )
558
- return 2
559
- namespace, verb, *extra = args
560
- # #976: detect and consume the --rest opt-in BEFORE any gh forwarding.
561
- # The flag is stripped from extra so the legacy path stays argv-pure.
562
- rest_mode, extra = _extract_flag(extra, "--rest")
563
- if rest_mode:
564
- if namespace != "issue" or verb not in _REST_OPT_IN_VERBS:
565
- print(
566
- f"error: --rest is only supported on 'issue {{view|list}}'; "
567
- f"got 'scm.py {namespace} {verb}'. Mutations (close, edit) "
568
- "still forward to gh in the v1 stub; #881 owns the full "
569
- "REST migration.",
570
- file=sys.stderr,
571
- )
572
- return 2
573
- if verb == "view":
574
- return _run_rest_view(extra)
575
- return _run_rest_list(extra)
576
-
577
- try:
578
- cmd = build_command(namespace, verb, extra)
579
- except ScmStubError as exc:
580
- print(f"error: {exc}", file=sys.stderr)
581
- return 2
582
-
583
- # subprocess.run with check=False so we forward the underlying exit code
584
- # rather than raising; gh's non-zero exits (e.g. issue not found) carry
585
- # actionable stderr that the caller already handles.
586
- proc = subprocess.run(cmd, check=False)
587
- return int(proc.returncode)
588
-
589
-
590
- if __name__ == "__main__":
591
- raise SystemExit(main())