@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
@@ -1,871 +0,0 @@
1
- #!/usr/bin/env python3
2
- """triage_actions.py -- per-issue triage decision commands (#845 Story 3).
3
-
4
- Provides eight commands consumed via ``tasks/triage-actions.yml``:
5
-
6
- - ``accept(n, repo)`` -- record an accept audit entry AND delegate the vBRIEF
7
- authoring to ``scripts/issue_ingest.py`` (#985). After ``log.append(entry)``
8
- succeeds, ``ingest_single_for_accept`` is invoked to materialise the issue
9
- in ``vbrief/proposed/`` per the refinement skill's three-tier inventory
10
- model. If the ingest fails, the audit entry is ROLLED BACK so the log
11
- never references an accept decision that did not actually produce a vBRIEF
12
- (mirrors :func:`reject`'s rollback pattern).
13
- - ``reject(n, repo, reason)`` -- close the upstream GitHub issue with
14
- ``gh issue close <n> --comment <reason> --reason 'not planned'``, apply the
15
- ``triage-rejected`` label, and record a reject audit entry. If the upstream
16
- ``gh`` call fails, the audit entry is ROLLED BACK so the log never references
17
- a decision that did not actually take effect.
18
- - ``defer(n, repo)`` -- record a defer audit entry.
19
- - ``needs_ac(n, repo)`` -- record a needs-ac audit entry and post an
20
- AC-request comment on the upstream issue.
21
- - ``mark_duplicate(n, repo, of_n)`` -- validate ``of_n`` exists in the local
22
- cache (Story 1) and record a mark-duplicate audit entry pointing at it.
23
- - ``status(n, repo)`` -- return the latest decision for ``n`` (None if none).
24
- - ``reset(n, repo)`` -- record a ``reset`` audit entry referencing the prior
25
- decision id. History is NEVER deleted; reset is the reversible exit.
26
- - ``history(n, repo)`` -- return all audit entries for ``n`` ordered by
27
- timestamp.
28
-
29
- All actions are idempotent on already-final state: invoking ``reject`` on an
30
- already-rejected issue is a no-op (returns the existing ``decision_id``) and
31
- does NOT re-call ``gh issue close`` nor re-write the audit log.
32
-
33
- Upstream contracts (frozen public surfaces of Story 2 + #883 Story 2):
34
-
35
- - ``scripts.candidates_log.append(entry: dict) -> str`` (decision_id)
36
- - ``scripts.candidates_log.latest_decision(issue_number: int, repo: str) -> dict | None``
37
- - ``scripts.candidates_log.find_by_issue(issue_number: int, repo: str) -> list[dict]``
38
- - ``scripts.cache.cache_get(source: str, key: str, *, allow_stale=True) -> GetResult``
39
- -- the unified cache replaces the legacy triage_cache.show(...) seam under
40
- #883 Story 3.
41
-
42
- The upstream PRs may not be merged when this script lands. Module-level
43
- ``candidates_log`` and ``cache`` references are therefore guarded with
44
- ``try / except ImportError`` so the module imports cleanly. Tests substitute
45
- fakes via ``monkeypatch.setattr(triage_actions, "candidates_log", ...)`` and
46
- ``monkeypatch.setattr(triage_actions, "cache", ...)``.
47
-
48
- Per ``conventions/task-caching.md`` the Taskfile fragment must NOT cache the
49
- ``cmds:`` block: every action accepts user-facing flags via ``{{.CLI_ARGS}}``.
50
- """
51
-
52
- from __future__ import annotations
53
-
54
- import argparse
55
- import contextlib
56
- import datetime as _dt
57
- import json
58
- import os
59
- import subprocess
60
- import sys
61
- import uuid
62
- from pathlib import Path
63
- from typing import Any
64
-
65
- # Make sibling scripts importable when invoked from the project root or via
66
- # ``uv run python scripts/triage_actions.py``. Mirrors the pattern in
67
- # ``scripts/policy_set.py`` so we can do ``import candidates_log``.
68
- sys.path.insert(0, str(Path(__file__).resolve().parent))
69
-
70
- # #1145 / N5: route ``gh`` invocations through the source-aware shim so a
71
- # future GitLab / Gitea / local consumer sees a loud ``NotImplementedError``
72
- # pointing at #445 / #935 Workstream 6, not a confusing
73
- # ``gh: command not found`` deep in the call stack. The shim resolves the
74
- # binary via the #884 ``ghx`` -> ``gh`` preference ladder, so this also
75
- # transparently picks up the cached proxy when it is installed.
76
- import scm # noqa: E402 -- sibling-first path insertion above is intentional
77
-
78
- # Public, frozen interfaces from #845 Story 2 (audit log) + #883 Story 2
79
- # (unified cache). These imports may fail when an upstream PR has not yet
80
- # merged onto the consumer's branch -- the module attributes are then
81
- # ``None`` and tests substitute a fake. Production bootstrap lands all
82
- # pieces together so the runtime path is intact.
83
- try: # pragma: no cover -- exercised once Story 2 lands.
84
- import candidates_log # type: ignore[import-not-found]
85
- except ImportError: # pragma: no cover
86
- candidates_log = None # type: ignore[assignment]
87
-
88
- try: # pragma: no cover -- exercised once #883 Story 2 lands.
89
- import cache # type: ignore[import-not-found]
90
- except ImportError: # pragma: no cover
91
- cache = None # type: ignore[assignment]
92
-
93
- # #985: triage:accept delegates the vBRIEF authoring to issue_ingest after
94
- # the audit-log append succeeds. Guarded so the module imports cleanly when
95
- # issue_ingest pulls in transitive deps (e.g. ``cache``) that may not be
96
- # present on a slimmed-down checkout. Tests substitute fakes via
97
- # ``monkeypatch.setattr(triage_actions, "issue_ingest", ...)``.
98
- try: # pragma: no cover -- exercised once #454 lands on the same checkout.
99
- import issue_ingest # type: ignore[import-not-found]
100
- except ImportError: # pragma: no cover
101
- issue_ingest = None # type: ignore[assignment]
102
-
103
- # Optional dep: resume-condition grammar parser (#1123 / D3). When
104
- # absent (slim test checkout) ``defer(resume_on=...)`` falls through
105
- # without pre-validation; the audit-log validator still accepts the
106
- # string verbatim.
107
- try: # pragma: no cover -- exercised once #1123 lands.
108
- import resume_conditions # type: ignore[import-not-found]
109
- except ImportError: # pragma: no cover
110
- resume_conditions = None # type: ignore[assignment]
111
-
112
-
113
- # Public constants ----------------------------------------------------------
114
-
115
- #: Project-relative path of the audit log written by Story 2's ``append``
116
- #: (canonical location frozen in the Story 2 vBRIEF). Used ONLY by
117
- #: :func:`_rollback_audit_entry` -- the normal write path goes through
118
- #: ``candidates_log.append``.
119
- AUDIT_LOG_REL_PATH = "vbrief/.eval/candidates.jsonl"
120
-
121
- #: Label applied to a rejected upstream issue alongside ``gh issue close``.
122
- REJECTED_LABEL = "triage-rejected"
123
-
124
- #: Default color (6-hex, no leading '#') applied when auto-creating
125
- #: :data:`REJECTED_LABEL` on a repository that lacks it (#1420). GitHub's
126
- #: own ``invalid`` / ``wontfix`` palette red; chosen so an auto-created
127
- #: label reads as a negative-disposition marker at a glance.
128
- REJECTED_LABEL_COLOR = "B60205"
129
-
130
- #: Description applied when auto-creating :data:`REJECTED_LABEL` (#1420).
131
- REJECTED_LABEL_DESCRIPTION = "Issue rejected during deft triage"
132
-
133
- #: Decision values we treat as terminal for idempotency purposes. Repeating
134
- #: the SAME terminal decision against an issue already in that state is a
135
- #: no-op (returns the prior decision_id, no audit / no upstream call).
136
- _TERMINAL_DECISIONS = frozenset({"accept", "reject", "mark-duplicate"})
137
-
138
- #: Default ``actor`` string when callers do not specify one.
139
- _DEFAULT_ACTOR = "agent:triage"
140
-
141
-
142
- def _now_iso() -> str:
143
- """Return an ISO-8601 UTC timestamp with the canonical ``Z`` suffix.
144
-
145
- Story 2's audit-log schema regex is ``\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}
146
- (\\.\\d+)?Z`` -- microseconds are accepted but we omit them so the on-disk
147
- string is easy to grep. Defined as a module-level callable so tests can
148
- monkeypatch it for deterministic, strictly-monotonic timestamps.
149
- """
150
- return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
151
-
152
-
153
- def _new_decision_id() -> str:
154
- """Generate a fresh UUID4 string for a new audit entry.
155
-
156
- Defers to ``candidates_log.new_decision_id()`` when the upstream module is
157
- importable so a future swap to UUID7 (time-ordered) is a one-file change.
158
- Falls back to ``uuid.uuid4()`` so this module remains self-contained when
159
- Story 2 is not yet on the branch (tests substitute a fake module anyway).
160
- """
161
- if candidates_log is not None and hasattr(candidates_log, "new_decision_id"):
162
- return str(candidates_log.new_decision_id())
163
- return str(uuid.uuid4())
164
-
165
-
166
- class TriageError(RuntimeError):
167
- """Raised when an action cannot complete (e.g. mark-duplicate target missing)."""
168
-
169
-
170
- class UpstreamCloseError(TriageError):
171
- """``gh issue close`` failed. The companion audit entry has been rolled back."""
172
-
173
-
174
- # Helpers -------------------------------------------------------------------
175
-
176
-
177
- def _audit_log_path(project_root: Path | None = None) -> Path:
178
- """Resolve the absolute path of the candidates audit log."""
179
- root = project_root or Path.cwd()
180
- return root / AUDIT_LOG_REL_PATH
181
-
182
-
183
- def _resolve_actor(actor: str | None) -> str:
184
- """Default the actor to the local user identity, falling back to a marker."""
185
- if actor:
186
- return actor
187
- return os.environ.get("USER") or os.environ.get("USERNAME") or _DEFAULT_ACTOR
188
-
189
-
190
- def _require_log() -> Any:
191
- """Return the live ``candidates_log`` module or raise if Story 2 is missing."""
192
- if candidates_log is None:
193
- raise TriageError(
194
- "scripts/candidates_log.py is not available in this checkout. "
195
- "Story 2 (#845) must land or this PR must be rebased onto master."
196
- )
197
- return candidates_log
198
-
199
-
200
- def _require_cache() -> Any:
201
- """Return the live ``cache`` module or raise if #883 Story 2 is missing."""
202
- if cache is None:
203
- raise TriageError(
204
- "scripts/cache.py is not available in this checkout. "
205
- "#883 Story 2 must land or this PR must be rebased onto master."
206
- )
207
- return cache
208
-
209
-
210
- def _run_gh(args: list[str]) -> subprocess.CompletedProcess[str]:
211
- """Wrapper around ``gh`` so tests can patch a single seam.
212
-
213
- Routes through :func:`scripts.scm.call` (#1145 / N5) so the binary
214
- resolution (the #884 ``ghx`` -> ``gh`` ladder) and the source-aware
215
- indirection (GitLab / Gitea / local raise
216
- :class:`NotImplementedError`) live in one place. Raises
217
- :class:`UpstreamCloseError` on non-zero exit so callers can roll back.
218
-
219
- The ``args`` list begins with the gh verb (e.g. ``"issue"``) followed
220
- by its subcommand and flags -- the shim accepts the verb separately
221
- so call sites do not have to know whether the underlying binary is
222
- ``gh`` or ``ghx``. An empty ``args`` is treated as a programming
223
- error and surfaces as :class:`UpstreamCloseError` (mirrors the prior
224
- ``FileNotFoundError`` failure mode at the contract layer).
225
- """
226
- if not args:
227
- raise UpstreamCloseError("scm.call requires at least a verb; got empty args")
228
- try:
229
- return scm.call(
230
- "github-issue",
231
- args[0],
232
- args[1:],
233
- check=True,
234
- capture_output=True,
235
- text=True,
236
- )
237
- except FileNotFoundError as exc:
238
- raise UpstreamCloseError(f"gh CLI not found on PATH: {exc}") from exc
239
- except scm.ScmStubError as exc:
240
- raise UpstreamCloseError(f"gh resolution failed: {exc}") from exc
241
- except subprocess.CalledProcessError as exc:
242
- stderr = (exc.stderr or "").strip()
243
- raise UpstreamCloseError(f"gh {' '.join(args)} failed: {stderr}") from exc
244
-
245
-
246
- def _rollback_audit_entry(decision_id: str, project_root: Path | None = None) -> bool:
247
- """Remove the audit-log line whose JSON ``decision_id`` matches.
248
-
249
- Story 2 documents an append-only contract for the normal flow; the
250
- rollback path is the explicit exceptional surface defined by the Story 3
251
- vBRIEF Constraint narrative ("On reject upstream-close failure, ROLL
252
- BACK the audit entry").
253
-
254
- The read+filter+rewrite cycle MUST be serialised against
255
- ``candidates_log.append`` -- otherwise a concurrent appender (e.g.
256
- Story 4 bulk ops) that commits between our ``open("r")`` and our
257
- ``write_text`` is silently clobbered, breaking the append-only
258
- guarantee for unrelated entries (Greptile #879 P1). We therefore
259
- acquire Story 2's own advisory lock primitive
260
- (``candidates_log._append_lock``) for the duration of the rewrite.
261
- The leading underscore is acknowledged: the alternative -- recreating
262
- the lock-file + msvcrt / fcntl dance from scratch here -- duplicates
263
- the cross-platform code path that Story 2 already encodes correctly.
264
-
265
- Returns True if a line was removed.
266
- """
267
- path = _audit_log_path(project_root)
268
- if not path.is_file():
269
- return False
270
-
271
- if candidates_log is not None and hasattr(candidates_log, "_append_lock"):
272
- lock_ctx = candidates_log._append_lock(path)
273
- else:
274
- lock_ctx = contextlib.nullcontext()
275
-
276
- kept: list[str] = []
277
- removed = False
278
- with lock_ctx:
279
- with path.open("r", encoding="utf-8") as fh:
280
- for raw in fh:
281
- stripped = raw.strip()
282
- if not stripped:
283
- continue
284
- try:
285
- entry = json.loads(stripped)
286
- except json.JSONDecodeError:
287
- # Preserve malformed lines verbatim (Story 2 read tolerates them).
288
- kept.append(raw if raw.endswith("\n") else raw + "\n")
289
- continue
290
- if not removed and entry.get("decision_id") == decision_id:
291
- removed = True
292
- continue
293
- kept.append(raw if raw.endswith("\n") else raw + "\n")
294
- if removed:
295
- path.write_text("".join(kept), encoding="utf-8")
296
- return removed
297
-
298
-
299
- def _build_entry(
300
- decision: str,
301
- issue_number: int,
302
- repo: str,
303
- *,
304
- actor: str,
305
- reason: str | None = None,
306
- linked_to: int | None = None,
307
- prior_decision_id: str | None = None,
308
- resume_on: str | None = None,
309
- ) -> dict[str, Any]:
310
- """Construct an audit-log entry that satisfies the Story 2 schema.
311
-
312
- The Story 2 ``candidates_log.append`` is a strict validator: it does NOT
313
- fill in ``decision_id`` / ``timestamp`` for the caller. We generate both
314
- here (using :func:`_new_decision_id` and :func:`_now_iso`) so every code
315
- path that lands an audit entry produces a valid record.
316
- """
317
- entry: dict[str, Any] = {
318
- "decision_id": _new_decision_id(),
319
- "timestamp": _now_iso(),
320
- "repo": repo,
321
- "issue_number": int(issue_number),
322
- "decision": decision,
323
- "actor": actor,
324
- }
325
- if reason is not None:
326
- entry["reason"] = reason
327
- if linked_to is not None:
328
- entry["linked_to"] = int(linked_to)
329
- if prior_decision_id is not None:
330
- entry["prior_decision_id"] = prior_decision_id
331
- if resume_on is not None:
332
- entry["resume_on"] = resume_on
333
- return entry
334
-
335
-
336
- def _is_idempotent_repeat(
337
- n: int, repo: str, decision: str, *, linked_to: int | None = None
338
- ) -> dict | None:
339
- """Return the prior entry if the requested action is a no-op."""
340
- if decision not in _TERMINAL_DECISIONS:
341
- return None
342
- log = _require_log()
343
- prior = log.latest_decision(n, repo)
344
- if prior is None:
345
- return None
346
- if prior.get("decision") != decision:
347
- return None
348
- # mark-duplicate idempotency requires the SAME target.
349
- if decision == "mark-duplicate" and prior.get("linked_to") != linked_to:
350
- return None
351
- return prior
352
-
353
-
354
- # Public action surface ----------------------------------------------------
355
-
356
-
357
- def accept(
358
- n: int,
359
- repo: str,
360
- *,
361
- actor: str | None = None,
362
- project_root: Path | None = None,
363
- ) -> str:
364
- """Record an accept audit entry AND delegate vBRIEF authoring to issue_ingest.
365
-
366
- Performs (in order):
367
-
368
- 1. Idempotency check -- if the issue is already accepted, return the
369
- prior ``decision_id`` without re-appending and WITHOUT re-ingesting.
370
- The pre-existing ``vbrief/proposed/`` artefact written on the first
371
- accept is preserved as-is.
372
- 2. Append the audit entry, capturing ``decision_id``.
373
- 3. Delegate to :func:`scripts.issue_ingest.ingest_single_for_accept` to
374
- materialise the issue as a scope vBRIEF in ``vbrief/proposed/``
375
- (per ``skills/deft-directive-refinement/SKILL.md`` Phase 0 Tier 3:
376
- "task triage:accept is the canonical write path -- it delegates the
377
- actual vBRIEF authoring to task issue:ingest so slug/reference/schema
378
- rules stay in one place"). The ingest call is cache-first per #883;
379
- slug rules + canonical reference shape stay owned by ``issue_ingest``
380
- per #537.
381
- 4. On ingest failure: roll the audit entry back via
382
- :func:`_rollback_audit_entry` and re-raise as :class:`TriageError`
383
- (mirrors :func:`reject`'s upstream-close-failure handling).
384
-
385
- Idempotency note: the idempotent short-circuit at step 1 deliberately
386
- skips both the audit append AND the ingest delegation -- a re-accept
387
- must NOT write a second proposed/ vBRIEF. Story 2's append-only audit
388
- log preserves the original ``decision_id`` and the slug-stable vBRIEF
389
- path keeps the original artefact reachable.
390
- """
391
- actor_str = _resolve_actor(actor)
392
- prior = _is_idempotent_repeat(n, repo, "accept")
393
- if prior is not None:
394
- return str(prior["decision_id"])
395
- log = _require_log()
396
- entry = _build_entry("accept", n, repo, actor=actor_str)
397
- decision_id = str(log.append(entry))
398
- try:
399
- _delegate_accept_ingest(n, repo, project_root=project_root)
400
- except Exception as exc: # noqa: BLE001 -- any ingest failure -> rollback
401
- _rollback_audit_entry(decision_id, project_root=project_root)
402
- # Surface as a structured TriageError so CLI / Taskfile callers exit
403
- # non-zero with an actionable message instead of a raw traceback.
404
- raise TriageError(
405
- f"accept #{n} ({repo}): issue:ingest delegation failed; "
406
- f"audit entry rolled back. Cause: {exc}"
407
- ) from exc
408
- return decision_id
409
-
410
-
411
- def _delegate_accept_ingest(
412
- n: int,
413
- repo: str,
414
- *,
415
- project_root: Path | None = None,
416
- ) -> None:
417
- """Invoke ``issue_ingest.ingest_single_for_accept`` for ``(repo, n)``.
418
-
419
- Raises :class:`TriageError` when ``scripts/issue_ingest.py`` is not
420
- importable in this checkout (mirrors :func:`_require_log` /
421
- :func:`_require_cache`). Any exception raised by the ingest path is
422
- propagated unchanged so :func:`accept` can roll the audit entry back
423
- with the original cause attached to the chained ``TriageError``.
424
- """
425
- if issue_ingest is None:
426
- raise TriageError(
427
- "scripts/issue_ingest.py is not available in this checkout. "
428
- "#454 (task issue:ingest) must land or this PR must be rebased "
429
- "onto master."
430
- )
431
- issue_ingest.ingest_single_for_accept(n, repo, project_root=project_root)
432
-
433
-
434
- def reject(
435
- n: int,
436
- repo: str,
437
- reason: str,
438
- *,
439
- actor: str | None = None,
440
- project_root: Path | None = None,
441
- ) -> str:
442
- """Close upstream + best-effort label + record. Roll back only on close failure.
443
-
444
- Performs (in order):
445
-
446
- 1. Idempotency check -- if the issue is already rejected, return the
447
- prior decision_id without re-calling gh.
448
- 2. Append the audit entry, capturing ``decision_id``.
449
- 3. ``gh issue close <n> --comment <reason> --reason 'not planned'``.
450
- 4. Best-effort ``gh issue edit <n> --add-label triage-rejected`` via
451
- :func:`_ensure_rejected_label_applied` -- self-healing when the
452
- label is missing on the repo (#1420).
453
- 5. On step 3 (close) failure ONLY: roll back the audit entry from the
454
- JSONL (per Story 3 vBRIEF Constraint) and re-raise as
455
- :class:`UpstreamCloseError`.
456
-
457
- #1420 -- label-application is NOT load-bearing. The close-with-reason
458
- is the decision that takes effect; once it succeeds the audit entry
459
- MUST persist. A repository that lacks the ``triage-rejected`` label
460
- used to fail step 4 and roll back the whole reject even though the
461
- issue was already closed. The reject flow now auto-creates the label
462
- when absent and, failing that, tolerates the missing label with a
463
- stderr warning -- it never rolls back a successful close.
464
- """
465
- actor_str = _resolve_actor(actor)
466
- prior = _is_idempotent_repeat(n, repo, "reject")
467
- if prior is not None:
468
- return str(prior["decision_id"])
469
- log = _require_log()
470
- entry = _build_entry("reject", n, repo, actor=actor_str, reason=reason)
471
- decision_id = str(log.append(entry))
472
- # Step 3: the close-with-reason is the load-bearing action -- a close
473
- # failure is the ONLY condition that rolls back the audit entry.
474
- try:
475
- _run_gh(
476
- [
477
- "issue",
478
- "close",
479
- str(n),
480
- "--repo",
481
- repo,
482
- "--comment",
483
- reason,
484
- "--reason",
485
- "not planned",
486
- ]
487
- )
488
- except UpstreamCloseError:
489
- _rollback_audit_entry(decision_id, project_root=project_root)
490
- raise
491
- # Step 4: label application is best-effort and self-healing. A missing
492
- # ``triage-rejected`` label MUST NOT roll back a successful close (#1420).
493
- _ensure_rejected_label_applied(n, repo)
494
- return decision_id
495
-
496
-
497
- def _looks_like_missing_label(exc: UpstreamCloseError) -> bool:
498
- """Heuristic: did ``gh issue edit --add-label`` fail because the label is absent?
499
-
500
- ``gh`` surfaces a missing label as ``"'triage-rejected' not found"`` (or
501
- a ``label ... not found`` variant). The check is intentionally broad --
502
- a false positive only triggers a (harmless, idempotent) label-create
503
- attempt, while a false negative would leave the #1420 bug unfixed.
504
- """
505
- text = str(exc).lower()
506
- return "not found" in text or "could not add label" in text
507
-
508
-
509
- def _ensure_label_exists(repo: str) -> None:
510
- """Create :data:`REJECTED_LABEL` on ``repo`` when it is missing (#1420).
511
-
512
- ``gh label create`` exits non-zero when the label already exists; that
513
- specific case is swallowed so a concurrent create or a pre-existing
514
- label is not treated as an error. Any other failure propagates as
515
- :class:`UpstreamCloseError` for the caller to tolerate (it must never
516
- roll back the already-closed issue).
517
- """
518
- try:
519
- _run_gh(
520
- [
521
- "label",
522
- "create",
523
- REJECTED_LABEL,
524
- "--repo",
525
- repo,
526
- "--description",
527
- REJECTED_LABEL_DESCRIPTION,
528
- "--color",
529
- REJECTED_LABEL_COLOR,
530
- ]
531
- )
532
- except UpstreamCloseError as exc:
533
- if "already exists" in str(exc).lower():
534
- return
535
- raise
536
-
537
-
538
- def _ensure_rejected_label_applied(n: int, repo: str) -> None:
539
- """Apply :data:`REJECTED_LABEL` to issue ``n``, auto-creating it if missing.
540
-
541
- Best-effort by contract (#1420): the caller has already closed the
542
- issue, so this helper MUST NOT raise -- a failure to label is surfaced
543
- on stderr but never rolls back the decision. The flow is:
544
-
545
- 1. Try ``gh issue edit --add-label triage-rejected``.
546
- 2. On a missing-label failure, create the label once and re-attempt.
547
- 3. On any continued failure, warn on stderr and return.
548
- """
549
- try:
550
- _run_gh(
551
- ["issue", "edit", str(n), "--repo", repo, "--add-label", REJECTED_LABEL]
552
- )
553
- return
554
- except UpstreamCloseError as add_exc:
555
- if not _looks_like_missing_label(add_exc):
556
- print(
557
- f"triage_actions: reject #{n} ({repo}) closed successfully but "
558
- f"the {REJECTED_LABEL!r} label could not be applied: {add_exc}",
559
- file=sys.stderr,
560
- )
561
- return
562
- # The label is absent on the repo -- create it once, then re-add.
563
- try:
564
- _ensure_label_exists(repo)
565
- _run_gh(
566
- ["issue", "edit", str(n), "--repo", repo, "--add-label", REJECTED_LABEL]
567
- )
568
- except UpstreamCloseError as heal_exc:
569
- print(
570
- f"triage_actions: reject #{n} ({repo}) closed successfully but the "
571
- f"{REJECTED_LABEL!r} label is missing and auto-create/re-add "
572
- f"failed: {heal_exc}",
573
- file=sys.stderr,
574
- )
575
-
576
-
577
- def defer(
578
- n: int,
579
- repo: str,
580
- reason: str | None = None,
581
- *,
582
- actor: str | None = None,
583
- resume_on: str | None = None,
584
- project_root: Path | None = None,
585
- ) -> str:
586
- """Record a defer audit entry (#1123 / D3 -- structured reason + resume_on).
587
-
588
- ``reason`` was free-text-only in #845 Story 3 and is now the structured
589
- rationale field on the audit entry (still optional at the API layer for
590
- back-compat with callers that pre-date #1123; the CLI surface treats it
591
- as required so new operator-driven defers always carry rationale).
592
-
593
- ``resume_on`` is the optional structured condition that the resume
594
- evaluator (`scripts/resume_conditions.evaluate_resume_eligibility`)
595
- will later consult to surface this defer as ``resume-eligible``.
596
- Pre-validated at write time when the ``resume_conditions`` module is
597
- importable so a malformed expression cannot land in the audit log.
598
- """
599
- if resume_on is not None and resume_conditions is not None:
600
- # Will raise ResumeGrammarError (ValueError subclass) on a bad
601
- # expression; we let it propagate as a TriageError-shaped
602
- # ValueError so CLI / Taskfile callers exit non-zero with the
603
- # parser's actionable message attached.
604
- try:
605
- resume_conditions.parse(resume_on)
606
- except resume_conditions.ResumeGrammarError as exc:
607
- raise TriageError(
608
- f"defer #{n} ({repo}): invalid --resume-on expression -- {exc}"
609
- ) from exc
610
- log = _require_log()
611
- entry = _build_entry(
612
- "defer",
613
- n,
614
- repo,
615
- actor=_resolve_actor(actor),
616
- reason=reason,
617
- resume_on=resume_on,
618
- )
619
- return str(log.append(entry))
620
-
621
-
622
- def needs_ac(
623
- n: int,
624
- repo: str,
625
- *,
626
- actor: str | None = None,
627
- comment: str | None = None,
628
- project_root: Path | None = None,
629
- ) -> str:
630
- """Record a needs-ac audit entry and post an AC-request comment upstream.
631
-
632
- The audit entry is appended FIRST so the trail records the request even
633
- if gh comment fails (this is a non-blocking signal -- we tolerate the
634
- upstream comment post failing without rolling back).
635
- """
636
- log = _require_log()
637
- body = comment or (
638
- "This issue lacks acceptance criteria. Please add a Test/Acceptance "
639
- "narrative before this can be triaged. (deft #845)"
640
- )
641
- entry = _build_entry("needs-ac", n, repo, actor=_resolve_actor(actor), reason=body)
642
- decision_id = str(log.append(entry))
643
- # Best-effort -- the audit entry is the source of truth; a failed
644
- # upstream comment is surfaced on stderr but does NOT roll back the
645
- # local trail. Greptile #879 P2: the prior `contextlib.suppress` here
646
- # contradicted this docstring's "logged" claim by silencing the error
647
- # entirely; the operator now sees the failure even when we keep the
648
- # audit entry.
649
- try:
650
- _run_gh(["issue", "comment", str(n), "--repo", repo, "--body", body])
651
- except UpstreamCloseError as exc:
652
- print(
653
- f"triage_actions: needs-ac comment not posted for #{n} "
654
- f"({repo}): {exc}",
655
- file=sys.stderr,
656
- )
657
- return decision_id
658
-
659
-
660
- def mark_duplicate(
661
- n: int,
662
- repo: str,
663
- of_n: int,
664
- *,
665
- actor: str | None = None,
666
- project_root: Path | None = None,
667
- ) -> str:
668
- """Validate target exists in unified cache + record mark-duplicate audit entry.
669
-
670
- Reads the target via :func:`scripts.cache.cache_get` (#883 Story 3 rebind
671
- onto cache:*). The ``allow_stale=True`` flag lets the validation succeed
672
- against an entry whose TTL has expired -- a stale-but-cached duplicate
673
- target is still an acceptable cross-link reference; the operator can
674
- refresh the entry later via ``task cache:fetch-all``.
675
- """
676
- if int(of_n) == int(n):
677
- raise TriageError(f"mark-duplicate target #{of_n} cannot equal source #{n}")
678
- cache_mod = _require_cache()
679
- key = f"{repo}/{int(of_n)}"
680
- try:
681
- cache_mod.cache_get("github-issue", key, allow_stale=True)
682
- except Exception as exc: # noqa: BLE001 -- cache may raise any error type
683
- raise TriageError(
684
- f"mark-duplicate target #{of_n} not found in cache for {repo}: {exc}"
685
- ) from exc
686
- prior = _is_idempotent_repeat(n, repo, "mark-duplicate", linked_to=int(of_n))
687
- if prior is not None:
688
- return str(prior["decision_id"])
689
- log = _require_log()
690
- entry = _build_entry(
691
- "mark-duplicate",
692
- n,
693
- repo,
694
- actor=_resolve_actor(actor),
695
- linked_to=int(of_n),
696
- )
697
- return str(log.append(entry))
698
-
699
-
700
- def status(n: int, repo: str) -> dict | None:
701
- """Return the latest decision for ``n`` in ``repo`` (None if none)."""
702
- log = _require_log()
703
- return log.latest_decision(n, repo)
704
-
705
-
706
- def reset(
707
- n: int,
708
- repo: str,
709
- *,
710
- actor: str | None = None,
711
- project_root: Path | None = None,
712
- ) -> str:
713
- """Record a reset audit entry referencing the prior decision_id.
714
-
715
- Reset is reversible: it does NOT delete history, it appends a new entry
716
- of type ``reset`` whose ``prior_decision_id`` is the most recent
717
- non-reset decision. Re-resetting an already-reset issue is a no-op.
718
- """
719
- log = _require_log()
720
- prior = log.latest_decision(n, repo)
721
- if prior is None:
722
- raise TriageError(f"cannot reset #{n}: no prior decision recorded for {repo}")
723
- if prior.get("decision") == "reset":
724
- return str(prior["decision_id"])
725
- entry = _build_entry(
726
- "reset",
727
- n,
728
- repo,
729
- actor=_resolve_actor(actor),
730
- prior_decision_id=str(prior["decision_id"]),
731
- )
732
- return str(log.append(entry))
733
-
734
-
735
- def history(n: int, repo: str) -> list[dict]:
736
- """Return audit entries for ``n`` ordered ascending by timestamp."""
737
- log = _require_log()
738
- entries = list(log.find_by_issue(n, repo))
739
- entries.sort(key=lambda e: str(e.get("timestamp", "")))
740
- return entries
741
-
742
-
743
- # CLI plumbing --------------------------------------------------------------
744
-
745
-
746
- def _format_decision(entry: dict | None) -> str:
747
- if entry is None:
748
- return "(no decision recorded)"
749
- parts = [
750
- f"decision={entry.get('decision')}",
751
- f"issue=#{entry.get('issue_number')}",
752
- f"repo={entry.get('repo')}",
753
- f"actor={entry.get('actor')}",
754
- f"timestamp={entry.get('timestamp')}",
755
- f"decision_id={entry.get('decision_id')}",
756
- ]
757
- if entry.get("reason"):
758
- parts.append(f"reason={entry['reason']!r}")
759
- if entry.get("linked_to") is not None:
760
- parts.append(f"linked_to=#{entry['linked_to']}")
761
- if entry.get("prior_decision_id"):
762
- parts.append(f"prior_decision_id={entry['prior_decision_id']}")
763
- return " " + " | ".join(parts)
764
-
765
-
766
- def _build_parser() -> argparse.ArgumentParser:
767
- parser = argparse.ArgumentParser(prog="triage_actions.py")
768
- sub = parser.add_subparsers(dest="cmd", required=True)
769
-
770
- def _common(p: argparse.ArgumentParser) -> None:
771
- p.add_argument("--issue", type=int, required=True, help="Issue number (N).")
772
- p.add_argument("--repo", required=True, help="Upstream repo as owner/name.")
773
- p.add_argument("--actor", default=None, help="Override the audit actor field.")
774
-
775
- for cmd in ("accept", "status", "reset", "history"):
776
- p = sub.add_parser(cmd)
777
- _common(p)
778
-
779
- # #1123: ``defer`` now requires --reason (replacing free-text defer)
780
- # and optionally accepts --resume-on.
781
- p_defer = sub.add_parser("defer")
782
- _common(p_defer)
783
- p_defer.add_argument(
784
- "--reason",
785
- required=True,
786
- help="Structured rationale captured on the defer audit entry (#1123).",
787
- )
788
- p_defer.add_argument(
789
- "--resume-on",
790
- default=None,
791
- dest="resume_on",
792
- help=(
793
- "Optional resume-condition expression (#1123). Grammar v1: "
794
- "ref:closed:#N | ref:merged:#N | date:>=YYYY-MM-DD | "
795
- "pending-count:>=N | pending-count:<=N, joined by AND/OR."
796
- ),
797
- )
798
-
799
- p_reject = sub.add_parser("reject")
800
- _common(p_reject)
801
- p_reject.add_argument("--reason", required=True)
802
-
803
- p_needs = sub.add_parser("needs-ac")
804
- _common(p_needs)
805
- p_needs.add_argument("--comment", default=None)
806
-
807
- p_dup = sub.add_parser("mark-duplicate")
808
- _common(p_dup)
809
- p_dup.add_argument("--of", type=int, required=True, dest="of_n")
810
-
811
- return parser
812
-
813
-
814
- def main(argv: list[str] | None = None) -> int:
815
- # N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
816
- from triage_help import intercept_help
817
-
818
- rc = intercept_help("triage_actions", argv)
819
- if rc is not None:
820
- return rc
821
- parser = _build_parser()
822
- args = parser.parse_args(argv)
823
- n = int(args.issue)
824
- repo = str(args.repo)
825
- actor = args.actor
826
-
827
- try:
828
- if args.cmd == "accept":
829
- decision_id = accept(n, repo, actor=actor)
830
- print(f"accept #{n} ({repo}) -> {decision_id}")
831
- elif args.cmd == "reject":
832
- decision_id = reject(n, repo, args.reason, actor=actor)
833
- print(f"reject #{n} ({repo}) -> {decision_id}")
834
- elif args.cmd == "defer":
835
- decision_id = defer(
836
- n,
837
- repo,
838
- args.reason,
839
- actor=actor,
840
- resume_on=getattr(args, "resume_on", None),
841
- )
842
- print(f"defer #{n} ({repo}) -> {decision_id}")
843
- elif args.cmd == "needs-ac":
844
- decision_id = needs_ac(n, repo, actor=actor, comment=args.comment)
845
- print(f"needs-ac #{n} ({repo}) -> {decision_id}")
846
- elif args.cmd == "mark-duplicate":
847
- decision_id = mark_duplicate(n, repo, args.of_n, actor=actor)
848
- print(f"mark-duplicate #{n} -> #{args.of_n} ({repo}) -> {decision_id}")
849
- elif args.cmd == "status":
850
- print(_format_decision(status(n, repo)))
851
- elif args.cmd == "reset":
852
- decision_id = reset(n, repo, actor=actor)
853
- print(f"reset #{n} ({repo}) -> {decision_id}")
854
- elif args.cmd == "history":
855
- entries = history(n, repo)
856
- if not entries:
857
- print(_format_decision(None))
858
- else:
859
- for entry in entries:
860
- print(_format_decision(entry))
861
- else: # pragma: no cover -- argparse enforces above set
862
- parser.print_help()
863
- return 2
864
- except TriageError as exc:
865
- print(f"triage_actions: {exc}", file=sys.stderr)
866
- return 1
867
- return 0
868
-
869
-
870
- if __name__ == "__main__":
871
- sys.exit(main())