@deftai/directive-content 0.55.1 → 0.56.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 (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,1153 @@
1
+ #!/usr/bin/env python3
2
+ """triage_bootstrap.py -- idempotent triage v1 installer (#883 Story 3 rebind).
3
+
4
+ Single-command opt-in for triage v1. Wires the consumer's project for
5
+ the pre-ingest triage workflow without touching any existing vBRIEF,
6
+ scope, or skill state. Designed to be re-runnable: every step is
7
+ idempotent and a second invocation is a no-op.
8
+
9
+ Steps (in order):
10
+
11
+ 1. ``populate_cache`` -- delegates to :func:`scripts.cache.cache_fetch_all`
12
+ with ``--source=github-issue`` to mirror upstream issues into
13
+ ``.deft-cache/github-issue/<owner>/<repo>/<N>/``. Gracefully degrades
14
+ to a deferred-action message when ``--repo`` is neither passed nor
15
+ inferable from ``git remote get-url origin`` and when the cache
16
+ module has not yet landed on the consumer's branch.
17
+
18
+ 2. ``backfill_audit_log`` -- writes one ``accept`` audit entry per
19
+ scope vBRIEF currently in ``vbrief/proposed/``, ``vbrief/pending/``,
20
+ or ``vbrief/active/``. Skips ``vbrief/cancelled/`` so rejected items
21
+ are NOT reanimated. Skips ``vbrief/completed/`` because completed
22
+ work is not in the triage funnel. Delegates to
23
+ :func:`scripts.candidates_log.append` when present; falls back to a
24
+ self-contained JSONL append when not.
25
+
26
+ 3. ``ensure_gitignore_entry`` -- append ``.deft-cache/`` to
27
+ ``.gitignore`` when absent.
28
+
29
+ 4. ``ensure_gitignore_eval_entries`` -- ensure the #1144 hybrid policy
30
+ is encoded: append the selective ``candidates.jsonl`` /
31
+ ``summary-history.jsonl`` / ``scope-lifecycle.jsonl`` entries to
32
+ ``.gitignore`` when missing, add the
33
+ ``vbrief/.eval/*.jsonl merge=union`` rule to ``.gitattributes``,
34
+ and write the canonical ``vbrief/.eval/README.md``. Replaces the
35
+ pre-#1251 ``ensure_gitignore_eval_dir`` which appended a blanket
36
+ ``vbrief/.eval/`` line that silently ignored the tracked
37
+ ``slices.jsonl`` (#1132 / D13).
38
+
39
+ 5. ``seed_candidates_log`` -- ensure ``vbrief/.eval/candidates.jsonl``
40
+ exists as a zero-length file so ``task verify:cache-fresh`` can
41
+ distinguish a never-bootstrapped consumer (no cache) from a
42
+ freshly-bootstrapped one (cache + empty audit log). Option A of
43
+ issue #1240.
44
+
45
+ Exit codes (three-state, mirrors ``scripts/preflight_branch.py``):
46
+
47
+ - ``0`` -- bootstrap completed (or all steps were no-ops on a re-run).
48
+ - ``1`` -- bootstrap failed at a runtime step.
49
+ - ``2`` -- config error: ``--project-root`` doesn't exist or isn't a
50
+ directory.
51
+
52
+ Refs:
53
+
54
+ - #883 (parent epic for the unified cache rebind).
55
+ - #845 (the pre-ingest triage workflow this script orchestrates).
56
+ - ``docs/privacy-nfr.md`` -- privacy contract for ``.deft-cache/``.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import argparse
62
+ import contextlib
63
+ import datetime as _dt
64
+ import importlib
65
+ import json
66
+ import os
67
+ import re
68
+ import shutil
69
+ import subprocess
70
+ import sys
71
+ import threading
72
+ import time
73
+ import uuid
74
+ from collections.abc import Callable
75
+ from dataclasses import dataclass, field
76
+ from pathlib import Path
77
+ from typing import Any
78
+
79
+ # Make sibling ``scripts`` modules importable when the consumer invokes
80
+ # this script via ``python scripts/triage_bootstrap.py`` from the
81
+ # project root.
82
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
83
+
84
+ # UTF-8-safe subprocess capture (#1366 / #1002). MUST be imported after the
85
+ # ``sys.path`` insert above so the sibling helper resolves whether deft is the
86
+ # project root or installed as a ``deft/`` subdirectory.
87
+ from _safe_subprocess import run_text # noqa: E402 -- needs sys.path insert above
88
+
89
+ # UTF-8 self-reconfigure (mirrors #814 fix). The Windows cp1252 default
90
+ # would crash on the ✓ / ⚠ glyphs we print in the recap.
91
+ for _stream in (sys.stdout, sys.stderr):
92
+ if hasattr(_stream, "reconfigure"):
93
+ with contextlib.suppress(AttributeError, ValueError):
94
+ _stream.reconfigure(encoding="utf-8", errors="replace")
95
+
96
+
97
+ #: Canonical cache-directory name. The unified cache writes to
98
+ #: ``.deft-cache/github-issue/<owner>/<repo>/<N>/`` under #883 Story 2.
99
+ CACHE_DIR_NAME = ".deft-cache"
100
+
101
+ #: Canonical audit-log path relative to the project root.
102
+ AUDIT_LOG_RELPATH = Path("vbrief") / ".eval" / "candidates.jsonl"
103
+
104
+ #: Lifecycle folders whose contents are backfilled with ``accept``
105
+ #: entries. ``cancelled/`` is excluded so rejected items are not
106
+ #: reanimated; ``completed/`` is excluded because completed work is no
107
+ #: longer in the triage funnel.
108
+ BACKFILL_FOLDERS = ("proposed", "pending", "active")
109
+
110
+ #: Canonical actor for bootstrap-emitted backfill entries.
111
+ BOOTSTRAP_ACTOR = "agent:bootstrap"
112
+
113
+ #: Cache source consumed by triage v1 (only github-issue is supported).
114
+ _CACHE_SOURCE: str = "github-issue"
115
+
116
+ #: Default wall-clock cap (seconds) on the cache:fetch-all step. The
117
+ #: underlying ``cache.cache_fetch_all`` shells out to ``task
118
+ #: scm:issue:view`` per issue with no per-call timeout, so a stuck
119
+ #: ``gh``/``ghx`` process (auth re-prompt, network stall, server-side
120
+ #: hang) will block the orchestrator indefinitely. The watchdog in
121
+ #: :func:`step_populate_cache` enforces this cap so the orchestrator
122
+ #: always exits in bounded time even when the underlying subprocess
123
+ #: tree is wedged. Override via ``--fetch-timeout-s`` or the
124
+ #: ``DEFT_BOOTSTRAP_FETCH_TIMEOUT_S`` env var. Set to ``0`` to disable
125
+ #: (legacy unbounded behavior). Sized for a 1000-issue full-backlog run
126
+ #: at the default 500ms inter-issue delay (#952).
127
+ DEFAULT_FETCH_TIMEOUT_S: int = 3600
128
+
129
+ #: subprocess.run timeout for ``git remote get-url origin``. Defensive:
130
+ #: a stuck ``git`` proxy (corporate VPN re-auth) would otherwise hang
131
+ #: bootstrap before any progress line is emitted.
132
+ _GIT_INFER_TIMEOUT_S: int = 10
133
+
134
+
135
+ @dataclass
136
+ class StepOutcome:
137
+ """Per-step result captured by the dispatcher."""
138
+
139
+ name: str
140
+ ok: bool
141
+ message: str
142
+ error: str | None = None
143
+ details: dict[str, Any] = field(default_factory=dict)
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Progress emit (#952)
148
+ # ---------------------------------------------------------------------------
149
+ #
150
+ # A real-world v0.26.0 backlog smoke (docs/smoke-2026-05-06-v0.26.0-scale.md)
151
+ # saw the orchestrator silently hang for 71+ minutes after cache:fetch-all
152
+ # *appeared* to complete -- the operator had no per-step visibility so could
153
+ # not tell whether the script was wedged inside step_populate_cache (the
154
+ # real culprit) or one of the post-fetch steps. The structured stderr lines
155
+ # below match the cadence of ``scripts/cache.py`` / ``cache_scanner.py``
156
+ # status output and let future operators (and the integration test) verify
157
+ # that each step is entered and exited.
158
+
159
+ #: Total number of steps executed by :func:`run_bootstrap`. Update if a
160
+ #: step is added or removed so the ``step <i>/<TOTAL>`` numerator stays
161
+ #: accurate. v0.32.0 (#1240): add step 5 ``seed_candidates_log``.
162
+ _TOTAL_STEPS: int = 5
163
+
164
+
165
+ def _emit_progress(
166
+ out: object | None,
167
+ step_index: int,
168
+ name: str,
169
+ phase: str,
170
+ detail: str = "",
171
+ ) -> None:
172
+ """Write a single ``triage:bootstrap step <i>/<N> <name> -- <phase>`` line.
173
+
174
+ ``out`` is any file-like object with a ``write()`` method (or ``None``
175
+ to silence emission, e.g. inside test fixtures that don't capture
176
+ stderr). The phase string is one of ``starting`` / ``done`` /
177
+ ``error`` / ``timeout``; callers are free to add a parenthetical
178
+ ``detail`` for cardinality (e.g. counts, repo, elapsed seconds).
179
+ """
180
+ if out is None:
181
+ return
182
+ line = f"triage:bootstrap step {step_index}/{_TOTAL_STEPS} {name} -- {phase}"
183
+ if detail:
184
+ line = f"{line} ({detail})"
185
+ try:
186
+ out.write(line + "\n")
187
+ flush = getattr(out, "flush", None)
188
+ if callable(flush):
189
+ flush()
190
+ except (OSError, ValueError):
191
+ # A closed-stream / broken-pipe write must never propagate from
192
+ # an observability path; the bootstrap result is canonical.
193
+ pass
194
+
195
+
196
+ #: Sentinel separating "func() returned None" from "runner thread died
197
+ #: before assigning result" (Greptile P1 cleanup for #955).
198
+ _RUNNER_UNSET: Any = object()
199
+
200
+
201
+ def _run_with_timeout(
202
+ func: Callable[[], Any],
203
+ timeout_s: float,
204
+ ) -> tuple[bool, Any, Exception | None]:
205
+ """Run ``func()`` in a daemon thread; return ``(completed, result, exc)``.
206
+
207
+ ``completed`` is False when ``timeout_s`` elapsed; the daemon thread
208
+ is left running (load-bearing property for #952). Non-positive
209
+ ``timeout_s`` disables the watchdog (legacy unbounded behavior).
210
+
211
+ Only :class:`Exception` is captured into ``box["exc"]``. A
212
+ :class:`BaseException` raised inside the daemon thread (``SystemExit`` /
213
+ ``MemoryError`` / ...) terminates the runner silently -- Python
214
+ threading does not propagate ``BaseException`` to the joining thread.
215
+ To stop that masquerading as ``ok=True`` with ``succeeded=None``,
216
+ ``box["result"]`` starts as a sentinel; a thread that joins without
217
+ setting either slot synthesizes a :class:`RuntimeError` so
218
+ :func:`step_populate_cache` reports ``ok=False`` (Greptile P1
219
+ cleanup for #955). Operator-issued Ctrl+C is unaffected.
220
+ """
221
+ box: dict[str, Any] = {"result": _RUNNER_UNSET, "exc": None}
222
+
223
+ def _runner() -> None:
224
+ try:
225
+ box["result"] = func()
226
+ except Exception as exc: # noqa: BLE001 -- forward verbatim
227
+ box["exc"] = exc
228
+
229
+ thread = threading.Thread(
230
+ target=_runner, name="triage_bootstrap.populate_cache", daemon=True
231
+ )
232
+ thread.start()
233
+ thread.join(timeout_s if timeout_s and timeout_s > 0 else None)
234
+ if thread.is_alive():
235
+ return False, None, None
236
+ if box["result"] is _RUNNER_UNSET and box["exc"] is None:
237
+ # Thread joined without result OR exc -- unhandled BaseException.
238
+ return True, None, RuntimeError(
239
+ "worker thread terminated without completing "
240
+ "(unhandled BaseException not propagated by Python threading)"
241
+ )
242
+ result = None if box["result"] is _RUNNER_UNSET else box["result"]
243
+ return True, result, box["exc"]
244
+
245
+
246
+ @dataclass
247
+ class BootstrapResult:
248
+ """Aggregate result returned by :func:`run_bootstrap`."""
249
+
250
+ project_root: Path
251
+ repo: str | None
252
+ steps: list[StepOutcome] = field(default_factory=list)
253
+ exit_code: int = 0
254
+
255
+ def summary(self) -> str:
256
+ """Render a recap the operator sees at the end of bootstrap."""
257
+
258
+ lines = ["", "Triage v1 bootstrap recap:"]
259
+ for step in self.steps:
260
+ mark = "✓" if step.ok else "✗"
261
+ lines.append(f" {mark} {step.name}: {step.message}")
262
+ if step.error:
263
+ lines.append(f" error: {step.error}")
264
+ if self.exit_code == 0:
265
+ lines.append("")
266
+ lines.append("Next steps:")
267
+ lines.append(
268
+ " task cache:fetch-all -- --source=github-issue "
269
+ "--repo OWNER/NAME # refresh the cache (#883 Story 2)"
270
+ )
271
+ lines.append(
272
+ " task cache:get -- github-issue OWNER/NAME/<N> "
273
+ "# inspect cached issue N"
274
+ )
275
+ lines.append(
276
+ " task triage:accept -- --issue <N> --repo OWNER/NAME "
277
+ "# accept issue N (#845 Story 3)"
278
+ )
279
+ lines.append(
280
+ " task triage:reject -- --issue <N> --repo OWNER/NAME --reason 'why' "
281
+ "# reject issue N"
282
+ )
283
+ lines.append(
284
+ " task triage:bulk-accept -- --repo OWNER/NAME --label adoption-blocker "
285
+ "# bulk accept"
286
+ )
287
+ lines.append(
288
+ " task triage:refresh-active "
289
+ "# pre-swarm freshness gate"
290
+ )
291
+ return "\n".join(lines)
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Repo resolution
296
+ # ---------------------------------------------------------------------------
297
+
298
+ #: Regex mapping a ``git remote get-url origin`` value to ``(owner, repo)``.
299
+ _GIT_ORIGIN_RE = re.compile(
300
+ r"^(?:https?://(?:[^@/]+@)?github\.com/|git@github\.com:|ssh://git@github\.com[:/])"
301
+ r"(?P<owner>[A-Za-z0-9][A-Za-z0-9._-]*)/"
302
+ r"(?P<repo>[A-Za-z0-9][A-Za-z0-9._-]*?)(?:\.git)?/?\s*$"
303
+ )
304
+ _REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$")
305
+
306
+
307
+ def _infer_repo_from_git(cwd: Path | None = None) -> str | None:
308
+ """Infer ``owner/repo`` from ``git remote get-url origin``.
309
+
310
+ A bounded ``timeout`` is applied to the subprocess call so a stuck
311
+ ``git`` proxy (corporate VPN re-auth, hung credential helper)
312
+ cannot wedge the orchestrator before any progress line lands
313
+ (#952 defensive). On timeout / OSError the function returns
314
+ ``None`` and the caller falls back to its existing skip-with-OK
315
+ branch.
316
+
317
+ The capture is routed through :func:`_safe_subprocess.run_text`
318
+ (#1366), which FORCES ``encoding="utf-8", errors="replace"`` so a
319
+ non-ASCII byte on the captured stream (e.g. a localized ``git``
320
+ warning on stderr) decodes to U+FFFD instead of crashing Python's
321
+ subprocess reader thread with ``UnicodeDecodeError`` under the
322
+ Windows cp1252 codepage (#1002, the #798 chain at the
323
+ subprocess-read surface).
324
+ """
325
+
326
+ if shutil.which("git") is None:
327
+ return None
328
+ try:
329
+ proc = run_text(
330
+ ["git", "remote", "get-url", "origin"],
331
+ cwd=str(cwd) if cwd is not None else None,
332
+ timeout=_GIT_INFER_TIMEOUT_S,
333
+ )
334
+ except (OSError, subprocess.SubprocessError):
335
+ return None
336
+ if proc.returncode != 0:
337
+ return None
338
+ url = (proc.stdout or "").strip()
339
+ if not url:
340
+ return None
341
+ m = _GIT_ORIGIN_RE.search(url)
342
+ if not m:
343
+ return None
344
+ return f"{m.group('owner')}/{m.group('repo')}"
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # Step 1 -- populate cache via cache:fetch-all
349
+ # ---------------------------------------------------------------------------
350
+
351
+
352
+ def _load_cache_module() -> Any | None:
353
+ """Return the unified cache module, or ``None`` if not importable."""
354
+
355
+ for candidate in ("cache", "scripts.cache"):
356
+ try:
357
+ return importlib.import_module(candidate)
358
+ except ModuleNotFoundError:
359
+ continue
360
+ return None
361
+
362
+
363
+ def step_populate_cache(
364
+ project_root: Path,
365
+ repo: str | None,
366
+ *,
367
+ cache_module: Any | None = None,
368
+ batch_size: int | None = None,
369
+ delay_ms: int | None = None,
370
+ state: str | None = None,
371
+ limit: int | None = None,
372
+ labels: tuple[str, ...] | None = None,
373
+ author: str | None = None,
374
+ fetch_timeout_s: float | None = None,
375
+ ) -> StepOutcome:
376
+ """Mirror upstream issues for ``repo`` via :func:`cache_fetch_all`.
377
+
378
+ ``labels`` (#1033) and ``author`` (#1055) scope the cache:fetch-all
379
+ enumeration so an operator can bootstrap against a subset of the
380
+ backlog (e.g. one label, or one author) instead of the entire open
381
+ queue. Both forward to :func:`cache.cache_fetch_all` and compose
382
+ with AND semantics when supplied together.
383
+
384
+ Resolution precedence for ``repo``:
385
+
386
+ 1. Explicit argument (kwargs / ``--repo`` flag / ``DEFT_TRIAGE_REPO`` env).
387
+ 2. Inference from ``git remote get-url origin`` inside ``project_root``.
388
+
389
+ When neither resolves, the step returns ``ok=True`` with a friendly
390
+ skip message -- the gitignore + audit-log steps are still useful
391
+ without a repo. When the cache module is missing on the branch the
392
+ step degrades to a deferred-action message so the bootstrap exit
393
+ code stays 0 (per the re-runnable contract).
394
+
395
+ ``fetch_timeout_s`` is the wall-clock cap on the wrapped
396
+ ``cache.cache_fetch_all`` call; ``None`` selects
397
+ :data:`DEFAULT_FETCH_TIMEOUT_S`. ``0`` disables the watchdog and
398
+ restores the legacy unbounded behavior. The watchdog is the load-
399
+ bearing fix for #952: a stuck ``task scm:issue:view`` subprocess
400
+ (auth re-prompt, network stall, server hang) can no longer wedge
401
+ the orchestrator past this cap, even though Python cannot
402
+ reliably interrupt the underlying process tree.
403
+ """
404
+
405
+ effective_repo = repo
406
+ if effective_repo is None:
407
+ effective_repo = _infer_repo_from_git(cwd=project_root)
408
+ if effective_repo is None:
409
+ return StepOutcome(
410
+ name="populate_cache",
411
+ ok=True,
412
+ message=(
413
+ "skipped (no --repo provided and could not infer from "
414
+ "`git remote get-url origin`; pass --repo OWNER/NAME)"
415
+ ),
416
+ details={"skipped": "no-repo"},
417
+ )
418
+ if not _REPO_RE.match(effective_repo):
419
+ return StepOutcome(
420
+ name="populate_cache",
421
+ ok=False,
422
+ message=f"invalid --repo {effective_repo!r}",
423
+ error="repo must be 'owner/name' (alphanumerics, '.', '_', '-' only)",
424
+ )
425
+
426
+ cache_mod = cache_module if cache_module is not None else _load_cache_module()
427
+ if cache_mod is None:
428
+ return StepOutcome(
429
+ name="populate_cache",
430
+ ok=True,
431
+ message=(
432
+ "deferred (scripts/cache.py not present on this branch; "
433
+ "re-run after rebase to populate via task cache:fetch-all)"
434
+ ),
435
+ details={"deferred": "cache-module-missing", "repo": effective_repo},
436
+ )
437
+ fetch_all = getattr(cache_mod, "cache_fetch_all", None)
438
+ if not callable(fetch_all):
439
+ return StepOutcome(
440
+ name="populate_cache",
441
+ ok=False,
442
+ message="cache_fetch_all is not callable",
443
+ error="#883 Story 2 contract violated: cache_fetch_all() not exposed",
444
+ )
445
+
446
+ kwargs: dict[str, Any] = {
447
+ "source": _CACHE_SOURCE,
448
+ "repo": effective_repo,
449
+ "cache_root": project_root / CACHE_DIR_NAME,
450
+ }
451
+ if batch_size is not None:
452
+ kwargs["batch_size"] = batch_size
453
+ if delay_ms is not None:
454
+ kwargs["delay_ms"] = delay_ms
455
+ if state is not None:
456
+ kwargs["state"] = state
457
+ if limit is not None:
458
+ kwargs["limit"] = limit
459
+ if labels:
460
+ kwargs["labels"] = labels
461
+ if author is not None:
462
+ kwargs["author"] = author
463
+
464
+ effective_timeout = (
465
+ fetch_timeout_s if fetch_timeout_s is not None else DEFAULT_FETCH_TIMEOUT_S
466
+ )
467
+
468
+ started = time.monotonic()
469
+ completed, report, exc = _run_with_timeout(
470
+ lambda: fetch_all(**kwargs), effective_timeout
471
+ )
472
+ elapsed = time.monotonic() - started
473
+
474
+ if not completed:
475
+ return StepOutcome(
476
+ name="populate_cache",
477
+ ok=False,
478
+ message=(
479
+ f"cache:fetch-all wall-clock timeout after "
480
+ f"{effective_timeout:g}s for repo={effective_repo} "
481
+ "(an underlying `task scm:issue:view` subprocess is likely "
482
+ "stuck; re-run with --fetch-timeout-s=0 to disable the "
483
+ "watchdog or with a higher value, or shrink the run via "
484
+ "--limit / --state=open)"
485
+ ),
486
+ error=(
487
+ f"step_populate_cache exceeded fetch_timeout_s={effective_timeout:g}; "
488
+ "see #952 for the watchdog rationale"
489
+ ),
490
+ details={
491
+ "repo": effective_repo,
492
+ "source": _CACHE_SOURCE,
493
+ "fetch_timeout_s": effective_timeout,
494
+ "elapsed_s": round(elapsed, 3),
495
+ "timed_out": True,
496
+ },
497
+ )
498
+
499
+ if exc is not None:
500
+ # cache_fetch_all raised; report the failure honestly so callers
501
+ # (and the orchestrator's recap) see a non-OK populate step. The
502
+ # bootstrap is partial -- ``run_bootstrap`` continues to the
503
+ # remaining (cache-independent) steps and surfaces ``exit_code=1``
504
+ # via the aggregate ``any(not step.ok)`` rule (P1 cleanup for
505
+ # #955; SLizard finding ``step_populate_cache misreports ok=True
506
+ # on exception``). Re-run after the underlying issue is resolved.
507
+ return StepOutcome(
508
+ name="populate_cache",
509
+ ok=False,
510
+ message=(
511
+ f"cache:fetch-all raised {type(exc).__name__} for repo="
512
+ f"{effective_repo} (re-run after the underlying issue is "
513
+ "resolved; see error for detail)"
514
+ ),
515
+ error=str(exc),
516
+ details={
517
+ "failed": "fetch-all-error",
518
+ "exc_type": type(exc).__name__,
519
+ "repo": effective_repo,
520
+ "elapsed_s": round(elapsed, 3),
521
+ "fetch_timeout_s": effective_timeout,
522
+ },
523
+ )
524
+
525
+ # #1247: FetchAllReport's counter names are being renamed to
526
+ # ``issues_written`` / ``already_fresh`` / ``issues_failed`` (PR
527
+ # #1254). When the new ``summary_line()`` renderer is available we
528
+ # delegate so the recap stays unambiguous; otherwise we fall back
529
+ # to the legacy hand-formatted string. The compatibility shim lets
530
+ # this PR land before or after #1254 without an ordering coupling.
531
+ succeeded = getattr(report, "succeeded", None)
532
+ failed = getattr(report, "failed", None)
533
+ skipped = getattr(report, "skipped", None)
534
+ summary_line = getattr(report, "summary_line", None)
535
+ legacy_message = (
536
+ f"cache:fetch-all source={_CACHE_SOURCE} repo={effective_repo} "
537
+ f"succeeded={succeeded} failed={failed} skipped={skipped}"
538
+ )
539
+ message = legacy_message
540
+ if callable(summary_line):
541
+ # Greptile P2 finding on PR #1256: pre-flight the kwarg shape
542
+ # via ``inspect.signature(...).bind(...)`` so a future
543
+ # signature change is the ONLY thing that re-routes us to the
544
+ # legacy path. A ``TypeError`` from inside ``summary_line()``
545
+ # itself (post-#1254 implementation bug) now propagates rather
546
+ # than silently falling back to a cryptic
547
+ # ``succeeded=None failed=None skipped=None`` recap.
548
+ import inspect
549
+
550
+ try:
551
+ sig = inspect.signature(summary_line)
552
+ except (TypeError, ValueError):
553
+ sig = None
554
+ kwargs_ok = True
555
+ if sig is not None:
556
+ try:
557
+ sig.bind(source=_CACHE_SOURCE, repo=effective_repo)
558
+ except TypeError:
559
+ kwargs_ok = False
560
+ if kwargs_ok:
561
+ message = summary_line(source=_CACHE_SOURCE, repo=effective_repo)
562
+ return StepOutcome(
563
+ name="populate_cache",
564
+ ok=True,
565
+ message=message,
566
+ details={
567
+ "repo": effective_repo,
568
+ "source": _CACHE_SOURCE,
569
+ "succeeded": succeeded,
570
+ "failed": failed,
571
+ "skipped": skipped,
572
+ "elapsed_s": round(elapsed, 3),
573
+ "fetch_timeout_s": effective_timeout,
574
+ },
575
+ )
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # Step 2 -- backfill audit log with `accept` entries
580
+ # ---------------------------------------------------------------------------
581
+
582
+
583
+ def _now_iso() -> str:
584
+ """Return current time as ISO-8601 UTC with the literal ``Z`` suffix."""
585
+
586
+ return (
587
+ _dt.datetime.now(tz=_dt.timezone.utc) # noqa: UP017
588
+ .replace(microsecond=0)
589
+ .strftime("%Y-%m-%dT%H:%M:%SZ")
590
+ )
591
+
592
+
593
+ def _extract_issue_number(vbrief_data: dict[str, Any]) -> int | None:
594
+ """Pull the issue number from a scope vBRIEF's references[] block."""
595
+
596
+ plan = vbrief_data.get("plan")
597
+ if not isinstance(plan, dict):
598
+ return None
599
+ refs = plan.get("references")
600
+ if not isinstance(refs, list):
601
+ return None
602
+ for ref in refs:
603
+ if not isinstance(ref, dict):
604
+ continue
605
+ if ref.get("type") != "x-vbrief/github-issue":
606
+ continue
607
+ uri = ref.get("uri", "")
608
+ if not isinstance(uri, str):
609
+ continue
610
+ tail = uri.rstrip("/").rsplit("/", 1)[-1]
611
+ if tail.isdigit():
612
+ return int(tail)
613
+ return None
614
+
615
+
616
+ def _scan_lifecycle_folder(folder: Path) -> list[tuple[int, Path]]:
617
+ """Walk a lifecycle folder, returning (issue_number, vbrief_path) tuples."""
618
+
619
+ results: list[tuple[int, Path]] = []
620
+ if not folder.exists() or not folder.is_dir():
621
+ return results
622
+ for path in sorted(folder.glob("*.vbrief.json")):
623
+ try:
624
+ data = json.loads(path.read_text(encoding="utf-8"))
625
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
626
+ continue
627
+ if not isinstance(data, dict):
628
+ continue
629
+ issue_number = _extract_issue_number(data)
630
+ if issue_number is None:
631
+ continue
632
+ results.append((issue_number, path))
633
+ return results
634
+
635
+
636
+ def _existing_audit_issue_numbers(audit_path: Path) -> set[int]:
637
+ """Read the audit log and return the set of issue numbers already logged."""
638
+
639
+ if not audit_path.exists():
640
+ return set()
641
+ seen: set[int] = set()
642
+ try:
643
+ for line in audit_path.read_text(encoding="utf-8").splitlines():
644
+ stripped = line.strip()
645
+ if not stripped:
646
+ continue
647
+ try:
648
+ entry = json.loads(stripped)
649
+ except json.JSONDecodeError:
650
+ continue
651
+ if not isinstance(entry, dict):
652
+ continue
653
+ n = entry.get("issue_number")
654
+ if isinstance(n, int):
655
+ seen.add(n)
656
+ except (OSError, UnicodeDecodeError):
657
+ return set()
658
+ return seen
659
+
660
+
661
+ def _build_audit_entry(repo: str, issue_number: int, source_folder: str) -> dict[str, Any]:
662
+ """Compose a single ``accept`` audit entry per Story 2's schema."""
663
+
664
+ return {
665
+ "decision_id": str(uuid.uuid4()),
666
+ "timestamp": _now_iso(),
667
+ "repo": repo,
668
+ "issue_number": issue_number,
669
+ "decision": "accept",
670
+ "actor": BOOTSTRAP_ACTOR,
671
+ "reason": (
672
+ f"bootstrap backfill: vBRIEF already in vbrief/{source_folder}/ "
673
+ "at opt-in time"
674
+ ),
675
+ }
676
+
677
+
678
+ def _append_audit_entry(audit_path: Path, entry: dict[str, Any]) -> None:
679
+ """Self-contained JSONL append used when Story 2 hasn't merged yet."""
680
+
681
+ audit_path.parent.mkdir(parents=True, exist_ok=True)
682
+ serialized = json.dumps(entry, ensure_ascii=False, sort_keys=True)
683
+ with audit_path.open("a", encoding="utf-8") as fh:
684
+ fh.write(serialized)
685
+ fh.write("\n")
686
+
687
+
688
+ def step_backfill_audit_log(project_root: Path, repo: str | None) -> StepOutcome:
689
+ """Backfill ``accept`` audit entries for items already in lifecycle folders."""
690
+
691
+ if repo is None:
692
+ return StepOutcome(
693
+ name="backfill_audit_log",
694
+ ok=True,
695
+ message=(
696
+ "skipped (no --repo provided; pass --repo OWNER/NAME to backfill)"
697
+ ),
698
+ details={"skipped": "no-repo"},
699
+ )
700
+
701
+ vbrief_root = project_root / "vbrief"
702
+ if not vbrief_root.exists() or not vbrief_root.is_dir():
703
+ return StepOutcome(
704
+ name="backfill_audit_log",
705
+ ok=True,
706
+ message=f"skipped (no vbrief/ directory under {project_root})",
707
+ details={"skipped": "no-vbrief"},
708
+ )
709
+
710
+ audit_path = project_root / AUDIT_LOG_RELPATH
711
+ already_logged = _existing_audit_issue_numbers(audit_path)
712
+
713
+ try:
714
+ candidates_log = importlib.import_module("candidates_log")
715
+ story2_append = getattr(candidates_log, "append", None)
716
+ if not callable(story2_append):
717
+ story2_append = None
718
+ except ModuleNotFoundError:
719
+ story2_append = None
720
+
721
+ appended = 0
722
+ skipped_existing = 0
723
+ skipped_cancelled = 0
724
+
725
+ cancelled_dir = vbrief_root / "cancelled"
726
+ if cancelled_dir.exists():
727
+ skipped_cancelled = len(_scan_lifecycle_folder(cancelled_dir))
728
+
729
+ for folder_name in BACKFILL_FOLDERS:
730
+ folder_path = vbrief_root / folder_name
731
+ for issue_number, _vbrief_path in _scan_lifecycle_folder(folder_path):
732
+ if issue_number in already_logged:
733
+ skipped_existing += 1
734
+ continue
735
+ entry = _build_audit_entry(repo, issue_number, folder_name)
736
+ try:
737
+ if story2_append is not None:
738
+ story2_append(entry, path=audit_path)
739
+ else:
740
+ _append_audit_entry(audit_path, entry)
741
+ except Exception as exc: # noqa: BLE001
742
+ return StepOutcome(
743
+ name="backfill_audit_log",
744
+ ok=False,
745
+ message=(
746
+ f"append failed at issue #{issue_number} after "
747
+ f"{appended} successful writes"
748
+ ),
749
+ error=f"{type(exc).__name__}: {exc}",
750
+ details={
751
+ "appended": appended,
752
+ "skipped_existing": skipped_existing,
753
+ "skipped_cancelled": skipped_cancelled,
754
+ },
755
+ )
756
+ appended += 1
757
+ already_logged.add(issue_number)
758
+
759
+ return StepOutcome(
760
+ name="backfill_audit_log",
761
+ ok=True,
762
+ message=(
763
+ f"appended {appended} accepted entries; skipped "
764
+ f"{skipped_existing} (already logged); skipped "
765
+ f"{skipped_cancelled} (cancelled/, no reanimation)"
766
+ ),
767
+ details={
768
+ "appended": appended,
769
+ "skipped_existing": skipped_existing,
770
+ "skipped_cancelled": skipped_cancelled,
771
+ "audit_path": str(audit_path),
772
+ },
773
+ )
774
+
775
+
776
+ # ---------------------------------------------------------------------------
777
+ # Step 3 + 4 -- ensure .deft-cache/ and vbrief/.eval/ are gitignored
778
+ # ---------------------------------------------------------------------------
779
+ #
780
+ # Implementation lives in scripts/_triage_bootstrap_gitignore.py to keep
781
+ # this module under the 1000-line MUST limit (coding/coding.md). The
782
+ # step functions are re-exported from that submodule so the public
783
+ # import surface (``triage_bootstrap.step_ensure_gitignore_entry`` /
784
+ # ``...eval_dir``) stays exactly as Story 3 shipped.
785
+
786
+ # Re-export the gitignore step functions and the canonical line
787
+ # constants. ``GITIGNORE_LINE`` / ``GITIGNORE_EVAL_ENTRIES`` /
788
+ # ``GITATTRIBUTES_EVAL_RULE`` are part of the module's public surface
789
+ # (consumers / tests reference ``triage_bootstrap.GITIGNORE_LINE``);
790
+ # the ``__all__``-style guard below keeps ruff F401 silent without
791
+ # losing the re-export. ``step_ensure_gitignore_eval_entries`` is the
792
+ # #1251 rename of the pre-existing ``step_ensure_gitignore_eval_dir``.
793
+ from _triage_bootstrap_gitignore import ( # noqa: E402, F401 -- re-exported public surface
794
+ GITATTRIBUTES_EVAL_RULE,
795
+ GITIGNORE_EVAL_ENTRIES,
796
+ GITIGNORE_LINE,
797
+ step_ensure_gitignore_entry,
798
+ step_ensure_gitignore_eval_entries,
799
+ step_seed_candidates_log,
800
+ )
801
+
802
+ # ---------------------------------------------------------------------------
803
+ # Dispatcher + CLI
804
+ # ---------------------------------------------------------------------------
805
+
806
+
807
+ #: Sentinel signalling that the caller did not pass a ``progress``
808
+ #: argument and the dispatcher should default to ``sys.stderr``. We
809
+ #: distinguish ``None`` (silent) from "not provided" (default to stderr)
810
+ #: so test callers can reliably suppress emission with ``progress=None``.
811
+ _PROGRESS_DEFAULT: object = object()
812
+
813
+
814
+ def run_bootstrap(
815
+ project_root: Path,
816
+ repo: str | None,
817
+ *,
818
+ cache_module: Any | None = None,
819
+ batch_size: int | None = None,
820
+ delay_ms: int | None = None,
821
+ state: str | None = None,
822
+ limit: int | None = None,
823
+ labels: tuple[str, ...] | None = None,
824
+ author: str | None = None,
825
+ fetch_timeout_s: float | None = None,
826
+ progress: Any = _PROGRESS_DEFAULT,
827
+ ) -> BootstrapResult:
828
+ """Run the bootstrap pipeline, returning the aggregate result.
829
+
830
+ Dispatches the five mutating steps documented in the module
831
+ docstring (populate_cache, backfill_audit_log,
832
+ ensure_gitignore_entry, ensure_gitignore_eval_entries,
833
+ seed_candidates_log) and appends one :class:`StepOutcome` per
834
+ step. ``len(result.steps) == 5`` is the expected post-condition.
835
+
836
+ Repo resolution (#1237): the explicit ``repo`` argument takes
837
+ priority. When ``None``, the dispatcher infers from ``git remote
838
+ get-url origin`` ONCE up-front and threads the result through every
839
+ downstream step. Pre-#1237 the populate step did the inference
840
+ inside itself but the backfill step did not, so step 2 silently
841
+ no-op'd with ``details.skipped="no-repo"`` on the happy path even
842
+ when step 1 had resolved a slug from git origin. Lifting the
843
+ resolution makes the four steps see the same answer for the same
844
+ invocation.
845
+
846
+ ``fetch_timeout_s`` is forwarded to :func:`step_populate_cache` and
847
+ bounds the cache:fetch-all step so the orchestrator always exits
848
+ even when an underlying subprocess hangs (#952).
849
+
850
+ ``progress`` is a file-like sink for per-step status lines; it
851
+ defaults to ``sys.stderr`` and may be set to ``None`` to silence
852
+ emission. The lines mirror ``scripts/cache.py`` cadence so a future
853
+ operator can see exactly which step is in flight if the run wedges.
854
+ """
855
+
856
+ progress_sink: Any = sys.stderr if progress is _PROGRESS_DEFAULT else progress
857
+
858
+ # #1237: resolve the repo ONCE so every downstream step sees the
859
+ # same answer. Mirrors the precedence chain used by
860
+ # ``step_populate_cache`` pre-#1237 (explicit -> git remote);
861
+ # consolidating it here eliminates the step-2 ``skipped=no-repo``
862
+ # gap documented on issue #1237.
863
+ effective_repo: str | None = repo
864
+ if effective_repo is None:
865
+ effective_repo = _infer_repo_from_git(cwd=project_root)
866
+
867
+ result = BootstrapResult(project_root=project_root, repo=effective_repo)
868
+
869
+ repo_detail = (
870
+ f"repo={effective_repo}" if effective_repo else "repo=<unresolved>"
871
+ )
872
+ effective_timeout = (
873
+ fetch_timeout_s if fetch_timeout_s is not None else DEFAULT_FETCH_TIMEOUT_S
874
+ )
875
+ timeout_detail = f"fetch_timeout_s={effective_timeout:g}"
876
+
877
+ _emit_progress(
878
+ progress_sink, 1, "populate_cache", "starting",
879
+ f"{repo_detail}; {timeout_detail}",
880
+ )
881
+ populate = step_populate_cache(
882
+ project_root,
883
+ effective_repo,
884
+ cache_module=cache_module,
885
+ batch_size=batch_size,
886
+ delay_ms=delay_ms,
887
+ state=state,
888
+ limit=limit,
889
+ labels=labels,
890
+ author=author,
891
+ fetch_timeout_s=fetch_timeout_s,
892
+ )
893
+ result.steps.append(populate)
894
+ populate_phase = "done" if populate.ok else (
895
+ "timeout" if populate.details.get("timed_out") else "error"
896
+ )
897
+ _emit_progress(
898
+ progress_sink, 1, "populate_cache", populate_phase, populate.message,
899
+ )
900
+
901
+ _emit_progress(progress_sink, 2, "backfill_audit_log", "starting", repo_detail)
902
+ backfill = step_backfill_audit_log(project_root, effective_repo)
903
+ result.steps.append(backfill)
904
+ _emit_progress(
905
+ progress_sink, 2, "backfill_audit_log",
906
+ "done" if backfill.ok else "error", backfill.message,
907
+ )
908
+
909
+ _emit_progress(progress_sink, 3, "ensure_gitignore_entry", "starting")
910
+ gi_cache = step_ensure_gitignore_entry(project_root)
911
+ result.steps.append(gi_cache)
912
+ _emit_progress(
913
+ progress_sink, 3, "ensure_gitignore_entry",
914
+ "done" if gi_cache.ok else "error", gi_cache.message,
915
+ )
916
+
917
+ _emit_progress(progress_sink, 4, "ensure_gitignore_eval_entries", "starting")
918
+ gi_eval = step_ensure_gitignore_eval_entries(project_root)
919
+ result.steps.append(gi_eval)
920
+ _emit_progress(
921
+ progress_sink, 4, "ensure_gitignore_eval_entries",
922
+ "done" if gi_eval.ok else "error", gi_eval.message,
923
+ )
924
+
925
+ # #1240 step 5: seed the audit log so verify:cache-fresh can tell
926
+ # "never bootstrapped" from "freshly bootstrapped, no triage
927
+ # actions yet". Always runs; independent of repo resolution.
928
+ _emit_progress(progress_sink, 5, "seed_candidates_log", "starting")
929
+ seed = step_seed_candidates_log(project_root)
930
+ result.steps.append(seed)
931
+ _emit_progress(
932
+ progress_sink, 5, "seed_candidates_log",
933
+ "done" if seed.ok else "error", seed.message,
934
+ )
935
+
936
+ if any(not step.ok for step in result.steps):
937
+ result.exit_code = 1
938
+ return result
939
+
940
+
941
+ def _build_parser() -> argparse.ArgumentParser:
942
+ parser = argparse.ArgumentParser(
943
+ prog="triage_bootstrap.py",
944
+ description=(
945
+ "Idempotent triage v1 installer (#883 Story 3 rebind). "
946
+ "Re-runnable by design; reversible via "
947
+ "`rm -rf .deft-cache/ vbrief/.eval/` and removing the "
948
+ ".deft-cache/ + vbrief/.eval/ lines from .gitignore."
949
+ ),
950
+ )
951
+ parser.add_argument(
952
+ "--project-root",
953
+ default=os.environ.get("DEFT_PROJECT_ROOT", "."),
954
+ help=(
955
+ "Path to the consumer project root (default: $DEFT_PROJECT_ROOT or "
956
+ "current working directory)."
957
+ ),
958
+ )
959
+ parser.add_argument(
960
+ "--repo",
961
+ default=os.environ.get("DEFT_TRIAGE_REPO"),
962
+ help=(
963
+ "Upstream repo slug 'owner/name'. Resolution precedence: "
964
+ "(1) this explicit flag; (2) the DEFT_TRIAGE_REPO env var; "
965
+ "(3) inferred from `git remote get-url origin` inside the populate "
966
+ "step. Bootstrap remains partial only when all three surfaces "
967
+ "fail to resolve a slug."
968
+ ),
969
+ )
970
+ parser.add_argument(
971
+ "--limit",
972
+ type=int,
973
+ default=None,
974
+ help=(
975
+ "Cap on the number of issues fetched (forwarded to "
976
+ "cache:fetch-all --limit)."
977
+ ),
978
+ )
979
+ parser.add_argument(
980
+ "--state",
981
+ default=None,
982
+ choices=["open", "closed", "all"],
983
+ help="Issue state filter forwarded to cache:fetch-all --state.",
984
+ )
985
+ parser.add_argument(
986
+ "--label",
987
+ action="append",
988
+ default=None,
989
+ dest="labels",
990
+ metavar="NAME[,NAME...]",
991
+ help=(
992
+ "Scope ingestion to issues carrying the given label(s) (#1033), "
993
+ "forwarded to cache:fetch-all --label. Repeatable and comma-"
994
+ "separated. Composes with --author via AND."
995
+ ),
996
+ )
997
+ parser.add_argument(
998
+ "--author",
999
+ default=None,
1000
+ metavar="LOGIN",
1001
+ help=(
1002
+ "Scope ingestion to issues created by LOGIN (#1055), forwarded "
1003
+ "to cache:fetch-all --author. Composes with --label via AND."
1004
+ ),
1005
+ )
1006
+ parser.add_argument(
1007
+ "--batch-size",
1008
+ type=int,
1009
+ default=None,
1010
+ dest="batch_size",
1011
+ help="Forwarded to cache:fetch-all --batch-size.",
1012
+ )
1013
+ parser.add_argument(
1014
+ "--delay-ms",
1015
+ type=int,
1016
+ default=None,
1017
+ dest="delay_ms",
1018
+ help="Forwarded to cache:fetch-all --delay-ms.",
1019
+ )
1020
+ parser.add_argument(
1021
+ "--fetch-timeout-s",
1022
+ type=float,
1023
+ default=_default_fetch_timeout_from_env(),
1024
+ dest="fetch_timeout_s",
1025
+ help=(
1026
+ "Wall-clock cap (seconds) on the cache:fetch-all step. The "
1027
+ "watchdog protects the orchestrator from a stuck `task "
1028
+ "scm:issue:view` subprocess so bootstrap always exits in "
1029
+ "bounded time (#952). 0 disables the cap (legacy unbounded "
1030
+ "behavior). Default: $DEFT_BOOTSTRAP_FETCH_TIMEOUT_S or "
1031
+ f"{DEFAULT_FETCH_TIMEOUT_S}s."
1032
+ ),
1033
+ )
1034
+ parser.add_argument(
1035
+ "--quiet",
1036
+ action="store_true",
1037
+ dest="quiet",
1038
+ help=(
1039
+ "Suppress per-step `triage:bootstrap step <i>/<N> ...` progress "
1040
+ "lines on stderr. The recap and --json output are unaffected."
1041
+ ),
1042
+ )
1043
+ parser.add_argument(
1044
+ "--json",
1045
+ action="store_true",
1046
+ dest="emit_json",
1047
+ help=(
1048
+ "Emit a structured JSON payload to stdout (one object per step) "
1049
+ "instead of the human-readable recap. Exit code is unchanged."
1050
+ ),
1051
+ )
1052
+ return parser
1053
+
1054
+
1055
+ def _normalise_label_filter(raw: list[str] | None) -> tuple[str, ...]:
1056
+ """Flatten repeated + comma-separated ``--label`` values into a tuple.
1057
+
1058
+ Mirrors ``cache._normalise_label_filter`` so the bootstrap and the
1059
+ underlying cache:fetch-all surface parse the multi-label convention
1060
+ identically (#1033). ``argparse(action="append")`` yields one list
1061
+ entry per flag occurrence; each entry may itself be comma-separated.
1062
+ """
1063
+ if not raw:
1064
+ return ()
1065
+ return tuple(
1066
+ item.strip()
1067
+ for value in raw
1068
+ for item in value.split(",")
1069
+ if item.strip()
1070
+ )
1071
+
1072
+
1073
+ def _default_fetch_timeout_from_env() -> float:
1074
+ """Resolve the default ``--fetch-timeout-s`` from the environment.
1075
+
1076
+ Reads ``DEFT_BOOTSTRAP_FETCH_TIMEOUT_S`` and falls back to
1077
+ :data:`DEFAULT_FETCH_TIMEOUT_S` on absence or unparseable value. A
1078
+ bad value is silently ignored (the CLI default is the canonical
1079
+ constant) so a misconfigured env never blocks an opt-in run.
1080
+ """
1081
+ raw = os.environ.get("DEFT_BOOTSTRAP_FETCH_TIMEOUT_S")
1082
+ if not raw:
1083
+ return float(DEFAULT_FETCH_TIMEOUT_S)
1084
+ try:
1085
+ return max(0.0, float(raw))
1086
+ except ValueError:
1087
+ return float(DEFAULT_FETCH_TIMEOUT_S)
1088
+
1089
+
1090
+ def _emit_json(result: BootstrapResult) -> str:
1091
+ """Render the structured ``--json`` payload."""
1092
+
1093
+ payload = {
1094
+ "project_root": str(result.project_root),
1095
+ "repo": result.repo,
1096
+ "exit_code": result.exit_code,
1097
+ "steps": [
1098
+ {
1099
+ "name": s.name,
1100
+ "ok": s.ok,
1101
+ "message": s.message,
1102
+ "error": s.error,
1103
+ "details": s.details,
1104
+ }
1105
+ for s in result.steps
1106
+ ],
1107
+ }
1108
+ return json.dumps(payload, sort_keys=True)
1109
+
1110
+
1111
+ def main(argv: list[str] | None = None) -> int:
1112
+ # N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
1113
+ from triage_help import intercept_help
1114
+
1115
+ rc = intercept_help("triage_bootstrap", argv)
1116
+ if rc is not None:
1117
+ return rc
1118
+ parser = _build_parser()
1119
+ args = parser.parse_args(argv)
1120
+
1121
+ project_root = Path(args.project_root).resolve()
1122
+ if not project_root.exists() or not project_root.is_dir():
1123
+ msg = (
1124
+ f"❌ triage:bootstrap: --project-root {project_root} does not exist "
1125
+ "or is not a directory."
1126
+ )
1127
+ print(msg, file=sys.stderr)
1128
+ return 2
1129
+
1130
+ labels = _normalise_label_filter(getattr(args, "labels", None))
1131
+ result = run_bootstrap(
1132
+ project_root=project_root,
1133
+ repo=args.repo,
1134
+ batch_size=args.batch_size,
1135
+ delay_ms=args.delay_ms,
1136
+ state=args.state,
1137
+ limit=args.limit,
1138
+ labels=labels,
1139
+ author=args.author,
1140
+ fetch_timeout_s=args.fetch_timeout_s,
1141
+ progress=None if args.quiet else sys.stderr,
1142
+ )
1143
+
1144
+ if args.emit_json:
1145
+ print(_emit_json(result))
1146
+ else:
1147
+ print(result.summary())
1148
+
1149
+ return result.exit_code
1150
+
1151
+
1152
+ if __name__ == "__main__":
1153
+ raise SystemExit(main())