@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,1178 @@
1
+ #!/usr/bin/env python3
2
+ """``task triage:welcome`` 6-phase onboarding ritual (#1143).
3
+
4
+ Consolidates triage bootstrap, subscription scope, wipCap, WIP relief,
5
+ summary, and triage-skill handoff into one idempotent walkthrough.
6
+ D4 (#1124) will replace the hand-rolled wipCap writer with the dedicated
7
+ policy-set surface once that parallel-wave work merges.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import json
14
+ import sys
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, field
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ # Make sibling scripts importable when invoked as
22
+ # ``python scripts/triage_welcome.py``.
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
+
25
+ from _lifecycle_hygiene import ( # noqa: E402 (sibling import after sys.path tweak)
26
+ detect_lifecycle_nudges,
27
+ record_tech_debt_acceptance,
28
+ resolve_epic_thresholds,
29
+ )
30
+ from _project_definition_io import ( # noqa: E402 (after sys.path tweak)
31
+ atomic_write_project_definition,
32
+ project_definition_mutation_lock,
33
+ )
34
+ from framework_commands import ( # noqa: E402
35
+ format_framework_command,
36
+ run_framework_command,
37
+ )
38
+ from policy import ( # noqa: E402 (sibling import after sys.path tweak)
39
+ count_pending_decisions,
40
+ pending_decisions_nudge_line,
41
+ )
42
+
43
+ # UTF-8 self-reconfigure -- the prompts emit ⊗ / · / arrows / checkmarks.
44
+ for _stream in (sys.stdout, sys.stderr):
45
+ if hasattr(_stream, "reconfigure"):
46
+ with contextlib.suppress(AttributeError, ValueError):
47
+ _stream.reconfigure(encoding="utf-8", errors="replace")
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Public constants
52
+ # ---------------------------------------------------------------------------
53
+
54
+ #: Filesystem-relative location of the PROJECT-DEFINITION vBRIEF.
55
+ PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json"
56
+
57
+ #: Canonical cache root + source (mirrors ``scripts/triage_summary.py``).
58
+ CACHE_DIR_NAME: str = ".deft-cache"
59
+ CACHE_SOURCE: str = "github-issue"
60
+
61
+ #: Canonical "bootstrap finished" audit log (#1244). Mirrors
62
+ #: :data:`scripts.preflight_cache.CANDIDATES_RELPATH` and
63
+ #: :data:`scripts.triage_bootstrap.AUDIT_LOG_RELPATH`. Downstream verbs
64
+ #: (`task triage:queue`, `task verify:cache-fresh`) all key off this
65
+ #: file's presence rather than the raw ``.deft-cache/`` entry count, so
66
+ #: welcome's Phase 3 idempotency probe MUST use the same signal.
67
+ CANDIDATES_RELPATH: tuple[str, ...] = ("vbrief", ".eval", "candidates.jsonl")
68
+
69
+ #: vBRIEF lifecycle folders that contribute to the WIP count.
70
+ WIP_LIFECYCLE_DIRS: tuple[str, ...] = ("pending", "active")
71
+
72
+ #: Audit log written by :func:`write_triage_scope` and :func:`write_wip_cap`.
73
+ #: Mirrors the location :mod:`policy` uses for branch-policy audit so
74
+ #: a future operator can grep one file for every policy mutation.
75
+ AUDIT_LOG_REL_PATH: str = "meta/policy-changes.log"
76
+
77
+ #: Default WIP cap per umbrella #1119 Current Shape v3 (comment 4471269010).
78
+ #: The legacy issue-body wording (``12``) is superseded; see #1124 / D4.
79
+ DEFAULT_WIP_CAP: int = 10
80
+
81
+ #: WIP-relief preview default age window (days). Issue body cites 30; the
82
+ #: companion D1 (#1121) default is 45 -- N3 honours the issue-body number
83
+ #: because welcome's job is consolidation, not policy. Override via the
84
+ #: relief prompt's `--older-than-days N` follow-up.
85
+ DEFAULT_RELIEF_AGE_DAYS: int = 30
86
+
87
+ #: Canonical pointer to the triage skill (#1130 / D6).
88
+ TRIAGE_SKILL_PATH: str = "skills/deft-directive-triage/SKILL.md"
89
+
90
+ #: Path to the framework's deterministic-questions contract.
91
+ DETERMINISTIC_QUESTIONS_PATH: str = "contracts/deterministic-questions.md"
92
+
93
+ #: Subscription preset rule shapes -- frozen per the issue body. The
94
+ #: framework default per the umbrella §12 framework-vs-consumer-config
95
+ #: boundary is ``[{"rule": "all-open"}]`` (Small). Mid and Mega are
96
+ #: consumer-agnostic generic shapes; deft-specific values live in
97
+ #: #1186 consumer-example (Wave-2e, intentionally separate).
98
+ SUBSCRIPTION_PRESETS: dict[str, list[dict[str, Any]]] = {
99
+ "small": [{"rule": "all-open"}],
100
+ "mid": [
101
+ {
102
+ "rule": "labels",
103
+ "any-of": ["urgent", "breaking", "security", "p0", "p1"],
104
+ },
105
+ {"rule": "opened-since", "duration": "60d"},
106
+ ],
107
+ "mega": [
108
+ {"rule": "explicit-watch", "issues": []},
109
+ {"rule": "referenced-by-vbrief", "scope": "active"},
110
+ ],
111
+ }
112
+
113
+ #: Audit sigil written to ``meta/policy-changes.log`` for triage-welcome.
114
+ WELCOME_AUDIT_TAG: str = "triage-welcome"
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Dataclass: detected prior state (Phase 1 output)
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class PriorState:
124
+ """Snapshot of the state probes Phase 1 needs.
125
+
126
+ ``audit_log_present`` is the canonical "bootstrap finished" signal
127
+ (#1244); the raw ``.deft-cache/`` entry count is diagnostic only.
128
+ """
129
+
130
+ triage_scope_set: bool
131
+ triage_scope_summary: str # human-readable label (e.g. "unset" / "Mid")
132
+ cache_empty: bool
133
+ cache_entry_count: int
134
+ wip_cap_set: bool
135
+ wip_cap: int # current value OR the DEFAULT_WIP_CAP fallback
136
+ wip_count: int # pending/ + active/
137
+ audit_log_present: bool # vbrief/.eval/candidates.jsonl exists (#1244)
138
+ # Pending human-clearance backlog count (#1419 Slice 5). Defaulted so any
139
+ # legacy direct construction stays valid; detect_prior_state always sets it.
140
+ pending_decisions: int = 0
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Helpers: PROJECT-DEFINITION reader + audit-log writer
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ def project_definition_path(project_root: Path | None = None) -> Path:
149
+ """Absolute path to ``vbrief/PROJECT-DEFINITION.vbrief.json``."""
150
+ root = project_root or Path.cwd()
151
+ return root / PROJECT_DEFINITION_REL_PATH
152
+
153
+
154
+ def _load_project_definition(project_root: Path) -> dict[str, Any] | None:
155
+ """Tolerant reader -- returns None on missing / malformed file."""
156
+ path = project_definition_path(project_root)
157
+ if not path.is_file():
158
+ return None
159
+ try:
160
+ data = json.loads(path.read_text(encoding="utf-8"))
161
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
162
+ return None
163
+ return data if isinstance(data, dict) else None
164
+
165
+
166
+ def _utc_iso(dt: datetime | None = None) -> str:
167
+ return (dt or datetime.now(UTC)).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
168
+
169
+
170
+ def append_audit_entry(project_root: Path, entry: str) -> Path:
171
+ """Append a one-line audit entry to ``meta/policy-changes.log``.
172
+
173
+ Atomic append-mode write (mirrors :func:`policy.append_audit_log`) so
174
+ concurrent welcome runs cannot lose entries on a torn write.
175
+ """
176
+ log_path = project_root / AUDIT_LOG_REL_PATH
177
+ log_path.parent.mkdir(parents=True, exist_ok=True)
178
+ line = f"{_utc_iso()} {entry}\n"
179
+ if not log_path.exists():
180
+ header = (
181
+ "# meta/policy-changes.log -- audit trail for "
182
+ "PROJECT-DEFINITION plan.policy.* mutations (#746 / #1143)\n"
183
+ )
184
+ log_path.write_text(header, encoding="utf-8")
185
+ with open(log_path, "a", encoding="utf-8") as handle:
186
+ handle.write(line)
187
+ return log_path
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Phase 1 -- prior-state detection
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ def _count_cache_entries(project_root: Path) -> int:
196
+ base = project_root / CACHE_DIR_NAME / CACHE_SOURCE
197
+ if not base.is_dir():
198
+ return 0
199
+ count = 0
200
+ for owner_dir in base.iterdir():
201
+ if not owner_dir.is_dir():
202
+ continue
203
+ for repo_dir in owner_dir.iterdir():
204
+ if not repo_dir.is_dir():
205
+ continue
206
+ for entry in repo_dir.iterdir():
207
+ if entry.is_dir() and entry.name.isdecimal():
208
+ count += 1
209
+ return count
210
+
211
+
212
+ def candidates_log_path(project_root: Path) -> Path:
213
+ """Absolute path to ``vbrief/.eval/candidates.jsonl`` (#1244)."""
214
+ return project_root.joinpath(*CANDIDATES_RELPATH)
215
+
216
+
217
+ def _audit_log_present(project_root: Path) -> bool:
218
+ """True iff ``vbrief/.eval/candidates.jsonl`` exists (zero-length OK)."""
219
+ return candidates_log_path(project_root).is_file()
220
+
221
+
222
+ def _count_wip(project_root: Path) -> int:
223
+ total = 0
224
+ root = project_root / "vbrief"
225
+ for sub in WIP_LIFECYCLE_DIRS:
226
+ folder = root / sub
227
+ if not folder.is_dir():
228
+ continue
229
+ total += sum(
230
+ 1
231
+ for child in folder.iterdir()
232
+ if child.is_file() and child.name.endswith(".vbrief.json")
233
+ )
234
+ return total
235
+
236
+
237
+ def _summarize_scope(rules: list[dict[str, Any]] | None) -> tuple[bool, str]:
238
+ """Return ``(set, label)`` for the operator-visible scope display."""
239
+ if not rules:
240
+ return False, "unset (default applied -- all-open)"
241
+ if rules == SUBSCRIPTION_PRESETS["small"]:
242
+ return True, "Small (all-open)"
243
+ if rules == SUBSCRIPTION_PRESETS["mid"]:
244
+ return True, "Mid (curated labels + opened-since 60d)"
245
+ # Compare without the (possibly populated) explicit-watch issues list.
246
+ mega_baseline = [dict(r) for r in SUBSCRIPTION_PRESETS["mega"]]
247
+ if len(rules) == len(mega_baseline):
248
+ match = True
249
+ for live, baseline in zip(rules, mega_baseline, strict=False):
250
+ if live.get("rule") != baseline.get("rule"):
251
+ match = False
252
+ break
253
+ if match:
254
+ return True, "Mega (explicit-watch + referenced-by-vbrief)"
255
+ return True, f"custom ({len(rules)} rule(s))"
256
+
257
+
258
+ def detect_prior_state(project_root: Path) -> PriorState:
259
+ """Read every Phase 1 probe in one pass. Pure -- no writes."""
260
+ data = _load_project_definition(project_root) or {}
261
+ plan = data.get("plan") if isinstance(data, dict) else None
262
+ policy = plan.get("policy") if isinstance(plan, dict) else None
263
+ raw_scope = policy.get("triageScope") if isinstance(policy, dict) else None
264
+ scope_rules = raw_scope if isinstance(raw_scope, list) else None
265
+
266
+ raw_cap = policy.get("wipCap") if isinstance(policy, dict) else None
267
+ if isinstance(raw_cap, int) and not isinstance(raw_cap, bool) and raw_cap >= 0:
268
+ wip_cap = raw_cap
269
+ wip_cap_set = True
270
+ else:
271
+ wip_cap = DEFAULT_WIP_CAP
272
+ wip_cap_set = False
273
+
274
+ scope_set, scope_label = _summarize_scope(scope_rules)
275
+ cache_count = _count_cache_entries(project_root)
276
+ return PriorState(
277
+ triage_scope_set=scope_set,
278
+ triage_scope_summary=scope_label,
279
+ cache_empty=cache_count == 0,
280
+ cache_entry_count=cache_count,
281
+ wip_cap_set=wip_cap_set,
282
+ wip_cap=wip_cap,
283
+ wip_count=_count_wip(project_root),
284
+ audit_log_present=_audit_log_present(project_root),
285
+ pending_decisions=count_pending_decisions(project_root),
286
+ )
287
+
288
+
289
+ def pending_decisions_oneliner(project_root: Path) -> str:
290
+ """Return the budgeted pending-human-decisions backlog one-liner (#1419 S5).
291
+
292
+ Surfaces the count derived from the durable audit log
293
+ (``vbrief/.audit/pending-human-decisions.jsonl``). When the backlog exceeds
294
+ the Tier-1 threshold the nudge text is appended so a session-start caller
295
+ can emit one actionable line. The headline is returned even when the
296
+ backlog is empty so callers may choose to show or suppress it. Additive /
297
+ localized so a later slice can wire it into the default-mode surface.
298
+ """
299
+ count = count_pending_decisions(project_root)
300
+ headline = f"[clearance] pending human decisions: {count}"
301
+ nudge = pending_decisions_nudge_line(count)
302
+ if not nudge:
303
+ return headline
304
+ return f"{headline} -- {nudge}"
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Phase 2 -- subscription scope writer (typed-flag pattern via #1131 surface)
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ def write_triage_scope(
313
+ project_root: Path,
314
+ rules: list[dict[str, Any]],
315
+ *,
316
+ preset_label: str,
317
+ actor: str = WELCOME_AUDIT_TAG,
318
+ ) -> tuple[bool, str]:
319
+ """In-place set ``plan.policy.triageScope`` to *rules*.
320
+
321
+ Returns ``(changed, audit_entry)``. Audit entry is appended whether
322
+ the value changed or not (the trail matters for re-run analysis).
323
+
324
+ Schema validation runs through ``scripts.triage_scope.validate_scope_rules``
325
+ when importable; a validation failure surfaces a clear error and refuses
326
+ the write. Pure-stdlib otherwise so the script runs without uv on PATH.
327
+ """
328
+ path = project_definition_path(project_root)
329
+ if not path.is_file():
330
+ raise FileNotFoundError(f"PROJECT-DEFINITION not found at {path}")
331
+
332
+ # Best-effort schema check via D12's validator. Tolerant of ImportError
333
+ # ONLY (e.g. triage_scope not yet on sys.path because uv sync has not
334
+ # run). Any other exception class -- SyntaxError, AttributeError, name
335
+ # collisions, validation ValueErrors -- MUST propagate so the caller
336
+ # learns about the real bug instead of silently dropping schema checks.
337
+ _validate = None
338
+ try:
339
+ from triage_scope import ( # type: ignore[import-not-found]
340
+ validate_scope_rules as _validate,
341
+ )
342
+ except ImportError:
343
+ _validate = None
344
+ if _validate is not None:
345
+ errors, _warnings = _validate(rules)
346
+ if errors:
347
+ joined = "; ".join(errors)
348
+ raise ValueError(f"plan.policy.triageScope schema errors: {joined}")
349
+
350
+ with project_definition_mutation_lock(project_root):
351
+ data = json.loads(path.read_text(encoding="utf-8"))
352
+ plan = data.setdefault("plan", {})
353
+ if not isinstance(plan, dict):
354
+ raise ValueError("PROJECT-DEFINITION 'plan' is not an object")
355
+ policy = plan.setdefault("policy", {})
356
+ if not isinstance(policy, dict):
357
+ raise ValueError("plan.policy is not an object")
358
+ previous = policy.get("triageScope")
359
+ policy["triageScope"] = rules
360
+ atomic_write_project_definition(path, data)
361
+
362
+ changed = previous != rules
363
+ audit_parts = [
364
+ f"actor={actor}",
365
+ "field=plan.policy.triageScope",
366
+ f"preset={preset_label}",
367
+ f"rule_count={len(rules)}",
368
+ f"changed={'true' if changed else 'false'}",
369
+ ]
370
+ audit_entry = " ".join(audit_parts)
371
+ append_audit_entry(project_root, audit_entry)
372
+ return changed, audit_entry
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # Phase 4 -- wipCap writer (hand-rolled until D4 / #1124 lands its surface)
377
+ # ---------------------------------------------------------------------------
378
+
379
+
380
+ def write_wip_cap(
381
+ project_root: Path,
382
+ wip_cap: int,
383
+ *,
384
+ actor: str = WELCOME_AUDIT_TAG,
385
+ ) -> tuple[bool, str]:
386
+ """Persist, omit, or clear ``plan.policy.wipCap`` per #1250.
387
+
388
+ Matrix: fresh default-confirm => no JSON write and no audit row;
389
+ existing override reset to default => remove the typed field and
390
+ audit cleanup; non-default values => materialize/audit the typed
391
+ override, with ``changed=false`` for same-value re-confirm.
392
+
393
+ Hand-rolled until D4 (#1124) lands the dedicated policy-set surface.
394
+ """
395
+ if not isinstance(wip_cap, int) or isinstance(wip_cap, bool) or wip_cap < 1:
396
+ raise ValueError(f"wipCap must be a positive int, got {wip_cap!r}")
397
+ path = project_definition_path(project_root)
398
+ if not path.is_file():
399
+ raise FileNotFoundError(f"PROJECT-DEFINITION not found at {path}")
400
+ with project_definition_mutation_lock(project_root):
401
+ data = json.loads(path.read_text(encoding="utf-8"))
402
+ plan = data.setdefault("plan", {})
403
+ if not isinstance(plan, dict):
404
+ raise ValueError("PROJECT-DEFINITION 'plan' is not an object")
405
+ policy = plan.setdefault("policy", {})
406
+ if not isinstance(policy, dict):
407
+ raise ValueError("plan.policy is not an object")
408
+ previous = policy.get("wipCap")
409
+
410
+ # Case 1: default-confirm on a fresh consumer -- the field stays
411
+ # omitted (#1250 / #1186 Deliverable 1). No JSON write, no audit row.
412
+ if previous is None and wip_cap == DEFAULT_WIP_CAP:
413
+ return False, ""
414
+
415
+ # Case 2: operator cleared back to the framework default -- remove the
416
+ # typed field so downstream resolvers report ``source=default``.
417
+ if previous is not None and wip_cap == DEFAULT_WIP_CAP:
418
+ del policy["wipCap"]
419
+ atomic_write_project_definition(path, data)
420
+ audit_entry = (
421
+ f"actor={actor} field=plan.policy.wipCap "
422
+ f"action=cleared-to-default value={wip_cap} "
423
+ f"previous={previous!r} changed=true"
424
+ )
425
+ append_audit_entry(project_root, audit_entry)
426
+ return True, audit_entry
427
+
428
+ # Case 3: explicit non-default write (including same-value re-confirm).
429
+ policy["wipCap"] = wip_cap
430
+ atomic_write_project_definition(path, data)
431
+
432
+ changed = previous != wip_cap
433
+ audit_entry = (
434
+ f"actor={actor} field=plan.policy.wipCap "
435
+ f"value={wip_cap} previous={previous!r} "
436
+ f"changed={'true' if changed else 'false'}"
437
+ )
438
+ append_audit_entry(project_root, audit_entry)
439
+ return changed, audit_entry
440
+
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # Phase 5 -- WIP-relief preview
444
+ # ---------------------------------------------------------------------------
445
+
446
+
447
+ @dataclass(frozen=True)
448
+ class ReliefPreview:
449
+ """Synthetic preview of a planned `scope:demote --batch` invocation."""
450
+
451
+ older_than_days: int
452
+ eligible_count: int
453
+ eligible_files: tuple[str, ...]
454
+ skipped_count: int
455
+
456
+
457
+ def preview_wip_relief(
458
+ project_root: Path,
459
+ older_than_days: int = DEFAULT_RELIEF_AGE_DAYS,
460
+ ) -> ReliefPreview:
461
+ """Walk ``vbrief/pending/`` and classify each vBRIEF by age.
462
+
463
+ Mirrors :func:`scope_demote.batch_demote`'s eligibility check without
464
+ invoking the writer. Pure -- the script consumes this to render the
465
+ `--dry-run` preview the issue body requires before any real demote.
466
+ """
467
+ pending_dir = project_root / "vbrief" / "pending"
468
+ if not pending_dir.is_dir():
469
+ return ReliefPreview(older_than_days, 0, (), 0)
470
+
471
+ now = datetime.now(UTC)
472
+ eligible: list[str] = []
473
+ skipped = 0
474
+ for candidate in sorted(pending_dir.glob("*.vbrief.json")):
475
+ days = _days_in_pending(candidate, now)
476
+ if days >= older_than_days:
477
+ eligible.append(candidate.name)
478
+ else:
479
+ skipped += 1
480
+ return ReliefPreview(
481
+ older_than_days=older_than_days,
482
+ eligible_count=len(eligible),
483
+ eligible_files=tuple(eligible),
484
+ skipped_count=skipped,
485
+ )
486
+
487
+
488
+ def _days_in_pending(path: Path, now: datetime) -> int:
489
+ """Approximate days-in-pending using ``plan.updated`` then file mtime."""
490
+ try:
491
+ data = json.loads(path.read_text(encoding="utf-8"))
492
+ plan = data.get("plan") if isinstance(data, dict) else None
493
+ raw = plan.get("updated") if isinstance(plan, dict) else None
494
+ if isinstance(raw, str):
495
+ text = raw.strip()
496
+ if text.endswith("Z"):
497
+ text = text[:-1] + "+00:00"
498
+ try:
499
+ stamp = datetime.fromisoformat(text)
500
+ except ValueError:
501
+ stamp = None
502
+ if stamp is not None:
503
+ delta = now - stamp.astimezone(UTC)
504
+ return max(0, int(delta.total_seconds() // 86400))
505
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
506
+ pass
507
+ try:
508
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
509
+ return max(0, int((now - mtime).total_seconds() // 86400))
510
+ except OSError:
511
+ return 0
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # Subprocess dispatch (Phase 3 + Phase 5 + Phase 6)
516
+ # ---------------------------------------------------------------------------
517
+
518
+
519
+ def normalize_task_prefix(task_prefix: str | None) -> str:
520
+ """Normalize an optional Taskfile include prefix (``deft`` -> ``deft:``)."""
521
+ prefix = (task_prefix or "").strip()
522
+ if prefix and not prefix.endswith(":"):
523
+ prefix = f"{prefix}:"
524
+ return prefix
525
+
526
+
527
+ def task_command_args(args: list[str], *, task_prefix: str | None = None) -> list[str]:
528
+ """Return task argv with *task_prefix* applied to the task name only."""
529
+ if not args:
530
+ return []
531
+ prefix = normalize_task_prefix(task_prefix)
532
+ return [f"{prefix}{args[0]}", *args[1:]]
533
+
534
+
535
+ def format_task_command(args: list[str], *, task_prefix: str | None = None) -> str:
536
+ """Render an operator-facing ``task ...`` command string."""
537
+ return " ".join(["task", *task_command_args(args, task_prefix=task_prefix)])
538
+
539
+
540
+ def format_welcome_command(args: list[str], *, task_prefix: str | None = None) -> str:
541
+ """Render the preferred command for welcome guidance (#1659)."""
542
+ prefix = normalize_task_prefix(task_prefix)
543
+ if prefix:
544
+ return format_framework_command(args, surface="task", task_prefix=prefix)
545
+ return format_framework_command(args, surface="deft")
546
+
547
+
548
+ def _run_task(args: list[str], *, cwd: Path, task_prefix: str | None = None) -> int:
549
+ """Run a Deft framework verb in-process. Returns exit code; never raises."""
550
+ _ = task_prefix # The no-task rail ignores Taskfile namespaces at runtime.
551
+ if not args:
552
+ return 2
553
+ command, *argv = args
554
+ result = run_framework_command(command, argv, project_root=cwd)
555
+ if result.stdout:
556
+ sys.stdout.write(result.stdout)
557
+ if result.stderr:
558
+ sys.stderr.write(result.stderr)
559
+ return result.code
560
+
561
+
562
+ # ---------------------------------------------------------------------------
563
+ # Interactive prompt helpers + CLI argparse shim live in
564
+ # ``scripts/_triage_welcome_cli.py`` so this module stays under the
565
+ # 500-line SHOULD ceiling from ``coding/coding.md``. The names below are
566
+ # re-exported for backward compatibility with importers / tests that
567
+ # reference them via ``triage_welcome.<name>``.
568
+ # ---------------------------------------------------------------------------
569
+
570
+ from _triage_welcome_cli import ( # noqa: E402,F401 (after sys.path tweak; _classify_onboarding + run_default_mode re-wrapped below)
571
+ FIRST_TIME_NUDGE,
572
+ INCOMPLETE_NUDGE_TEMPLATE,
573
+ PromptOutcome,
574
+ _classify_onboarding,
575
+ default_input,
576
+ default_output,
577
+ emit_oneliner,
578
+ prompt_int,
579
+ prompt_menu,
580
+ prompt_yes_no,
581
+ run_default_mode as _cli_run_default_mode,
582
+ )
583
+
584
+ # ---------------------------------------------------------------------------
585
+ # Session-start lifecycle-hygiene nudges (#1419 Slice 6)
586
+ # ---------------------------------------------------------------------------
587
+
588
+ #: Overflow pointer appended when the budget hides additional ranked nudges.
589
+ NUDGE_OVERFLOW_POINTER: str = "`deft capacity:show`"
590
+
591
+ #: Default session-start nudge budget (RFC #1419 Nudge Budgeting: budget 1).
592
+ DEFAULT_NUDGE_BUDGET: int = 1
593
+
594
+
595
+ def lifecycle_nudge_lines(
596
+ project_root: Path, *, now: datetime | None = None
597
+ ) -> list[str]:
598
+ """Rendered lifecycle-hygiene nudge lines (#1419 Slice 6), Tier-ranked.
599
+
600
+ Thin adapter over :func:`_lifecycle_hygiene.detect_lifecycle_nudges` that
601
+ returns just the rendered one-line messages (stranded-slice Tier-1 +
602
+ stale-epic Tier-2), already sorted most-harmful-first. Unbudgeted -- the
603
+ verbose onboard readout emits all of them.
604
+ """
605
+ return [nudge.message for nudge in detect_lifecycle_nudges(project_root, now=now)]
606
+
607
+
608
+ def session_start_nudge_lines(
609
+ project_root: Path,
610
+ *,
611
+ budget: int = DEFAULT_NUDGE_BUDGET,
612
+ now: datetime | None = None,
613
+ ) -> list[str]:
614
+ """Shared, budgeted session-start nudge ranking (#1419 Slice 6).
615
+
616
+ Merges the Slice-5 pending-human-decisions backlog (Tier-1) with the
617
+ Slice-6 lifecycle-hygiene nudges (stranded-slice Tier-1, stale-epic
618
+ Tier-2) into one ranked list -- ``(tier, -magnitude, id)`` -- and returns
619
+ at most *budget* headline lines plus a single ``+N more`` overflow pointer
620
+ at ``deft capacity:show`` when more nudges remain. This is the budgeted
621
+ default-mode surface; the full ranked list lives in ``capacity:show``.
622
+ """
623
+ ranked: list[tuple[int, int, str, str]] = []
624
+ count = count_pending_decisions(project_root)
625
+ backlog_nudge = pending_decisions_nudge_line(count)
626
+ if backlog_nudge:
627
+ # Tier-1; magnitude = backlog size (negated at sort time for desc).
628
+ ranked.append((1, count, "pending-decisions", backlog_nudge))
629
+ for nudge in detect_lifecycle_nudges(project_root, now=now):
630
+ ranked.append((nudge.tier, nudge.magnitude, nudge.nudge_id, nudge.message))
631
+ # Ranking is tier-primary (rate-of-harm), then a coarse magnitude tiebreaker,
632
+ # then id. NOTE (#1508 review): within a tier the magnitude units are
633
+ # intentionally NOT normalized in v1 -- a lifecycle nudge's magnitude is
634
+ # dormancy-days while the backlog's is a decision count, so dormancy-days
635
+ # effectively dominates same-tier ordering. That is acceptable because the
636
+ # budgeted surface only shows the single top headline plus a `+N more`
637
+ # pointer; the full, separately-grouped list lives in `deft capacity:show`.
638
+ ranked.sort(key=lambda item: (item[0], -item[1], item[2]))
639
+
640
+ budget = max(0, budget)
641
+ lines = [message for *_rest, message in ranked[:budget]]
642
+ overflow = len(ranked) - len(lines)
643
+ if overflow > 0:
644
+ lines.append(
645
+ f" +{overflow} more lifecycle/capacity nudge(s) -- run "
646
+ f"{NUDGE_OVERFLOW_POINTER} for the full ranked list"
647
+ )
648
+ return lines
649
+
650
+
651
+ def run_default_mode(
652
+ project_root: Path,
653
+ *,
654
+ output_fn: Callable[[str], None] | None = None,
655
+ write_history: bool = True,
656
+ now: datetime | None = None,
657
+ task_prefix: str | None = None,
658
+ ) -> WelcomeOutcome:
659
+ """Default-mode session-start surface (#1309) + budgeted nudges (#1419 S6).
660
+
661
+ Delegates to the #1309 default-mode implementation in
662
+ :mod:`_triage_welcome_cli` (summary one-liner + onboarding nudge), then
663
+ appends the budgeted shared session-start nudge ranking so the
664
+ lifecycle-hygiene nudges ride the same surface as the Slice-5 backlog
665
+ one-liner. Always advisory -- never changes the delegate's exit code.
666
+
667
+ *now* is forwarded to the lifecycle detector so callers / tests can pin a
668
+ deterministic clock; ``None`` uses the real clock (#1508 review).
669
+ """
670
+ out_fn = output_fn or default_output
671
+ outcome = _cli_run_default_mode(
672
+ project_root,
673
+ output_fn=out_fn,
674
+ write_history=write_history,
675
+ task_prefix=task_prefix,
676
+ )
677
+ for line in session_start_nudge_lines(project_root, now=now):
678
+ out_fn(line)
679
+ return outcome
680
+
681
+
682
+ # Re-export names for callers / tests reading them off this module. Kept
683
+ # compact (single sorted tuple) so the file stays under the 1000-line
684
+ # MUST cap from ``coding/coding.md`` while still serving any future
685
+ # ``from triage_welcome import *`` consumer.
686
+ __all__ = (
687
+ "AUDIT_LOG_REL_PATH", "CACHE_DIR_NAME", "CACHE_SOURCE",
688
+ "CANDIDATES_RELPATH", "DEFAULT_NUDGE_BUDGET", "DEFAULT_RELIEF_AGE_DAYS",
689
+ "DEFAULT_WIP_CAP", "FIRST_TIME_NUDGE", "INCOMPLETE_NUDGE_TEMPLATE",
690
+ "NUDGE_OVERFLOW_POINTER", "PROJECT_DEFINITION_REL_PATH", "PriorState",
691
+ "PromptOutcome", "ReliefPreview", "SUBSCRIPTION_PRESETS",
692
+ "TRIAGE_SKILL_PATH", "WELCOME_AUDIT_TAG", "WIP_LIFECYCLE_DIRS",
693
+ "WelcomeOutcome", "append_audit_entry", "candidates_log_path",
694
+ "default_input", "default_output", "detect_lifecycle_nudges",
695
+ "detect_prior_state", "emit_oneliner", "lifecycle_nudge_lines", "main",
696
+ "pending_decisions_oneliner", "preview_wip_relief",
697
+ "project_definition_path", "prompt_int", "prompt_menu", "prompt_yes_no",
698
+ "record_tech_debt_acceptance", "resolve_epic_thresholds",
699
+ "format_task_command", "format_welcome_command", "normalize_task_prefix", "run_default_mode",
700
+ "run_welcome", "session_start_nudge_lines", "task_command_args",
701
+ "write_triage_scope", "write_wip_cap",
702
+ )
703
+
704
+
705
+ # ---------------------------------------------------------------------------
706
+ # Ritual orchestrator
707
+ # ---------------------------------------------------------------------------
708
+
709
+
710
+ #: ``WelcomeOutcome.bootstrap_action`` tokens (#1244). Compare via these
711
+ #: constants -- a rename then surfaces as a NameError at import time.
712
+ BOOTSTRAP_ACTION_RAN = "ran"
713
+ BOOTSTRAP_ACTION_SKIPPED_ALREADY_BOOTSTRAPPED = "skipped:already-bootstrapped"
714
+ BOOTSTRAP_ACTION_SKIPPED_DECLINED = "skipped:declined"
715
+ BOOTSTRAP_ACTION_SKIPPED_DRY_MODE = "skipped:dry-mode"
716
+
717
+
718
+ @dataclass
719
+ class WelcomeOutcome:
720
+ """End-of-run summary for tests / dispatcher consumers.
721
+
722
+ ``bootstrap_action`` (#1244) surfaces whether Phase 3 invoked
723
+ ``deft triage:bootstrap`` or skipped it (and why). One of the
724
+ ``BOOTSTRAP_ACTION_*`` constants above, or ``None`` if the ritual
725
+ exited before Phase 3 (e.g. Discuss / Back at Phase 2).
726
+ """
727
+
728
+ phases_run: list[int] = field(default_factory=list)
729
+ phases_skipped: list[int] = field(default_factory=list)
730
+ subscription_choice: str | None = None
731
+ wip_cap_choice: int | None = None
732
+ relief_offered: bool = False
733
+ relief_confirmed: bool = False
734
+ discussed_at_phase: int | None = None
735
+ exit_code: int = 0
736
+ bootstrap_action: str | None = None
737
+
738
+
739
+ def run_welcome(
740
+ project_root: Path,
741
+ *,
742
+ input_fn: Callable[[str], str] | None = None,
743
+ output_fn: Callable[[str], None] | None = None,
744
+ run_subprocess: bool = True,
745
+ skip_bootstrap: bool = False,
746
+ task_prefix: str | None = None,
747
+ ) -> WelcomeOutcome:
748
+ """Execute the 6-phase ritual. Returns a structured outcome.
749
+
750
+ Phases 1-6 run inside a single ``while True`` loop so the
751
+ deterministic-questions ``Back`` semantic re-renders the prior
752
+ question (a Back at Phase 4 rewinds to Phase 2 with
753
+ ``force_re_prompt_*`` overriding the already-set-skip). A Discuss
754
+ selection returns immediately. Subprocess failures in Phases 3 and
755
+ 5 set ``outcome.exit_code = 2``.
756
+
757
+ Phase 3 bootstrap-skip semantics (#1244): the canonical "bootstrap
758
+ already finished" signal is ``vbrief/.eval/candidates.jsonl`` (the
759
+ audit log seeded by ``deft triage:bootstrap`` step 5), NOT the raw
760
+ ``.deft-cache/`` entry count. When the audit log is absent the
761
+ ritual MUST (a) run bootstrap (the default, idempotent), (b) loudly
762
+ surface dry-mode suppression when ``run_subprocess=False``, or
763
+ (c) record an explicit operator decline via ``skip_bootstrap=True``
764
+ and append a visible audit entry.
765
+ """
766
+ in_fn = input_fn or default_input
767
+ out_fn = output_fn or default_output
768
+ outcome = WelcomeOutcome()
769
+ normalized_task_prefix = normalize_task_prefix(task_prefix)
770
+
771
+ def _display_task(args: list[str]) -> str:
772
+ return format_welcome_command(args, task_prefix=normalized_task_prefix)
773
+
774
+ def _run_welcome_task(args: list[str]) -> int:
775
+ return _run_task(args, cwd=project_root)
776
+
777
+ # ``Back`` overrides the "already set, skip" rule for ONE iteration so
778
+ # the operator can re-answer the question they rewound to. Consumed at
779
+ # the top of the corresponding phase block.
780
+ force_re_prompt_phase_2 = False
781
+ force_re_prompt_phase_4 = False
782
+
783
+ # Tracking sets prevent duplicate phases_run / phases_skipped entries
784
+ # when the loop revisits a phase via Back.
785
+ phases_run_seen: set[int] = set()
786
+ phases_skipped_seen: set[int] = set()
787
+
788
+ def _record_run(n: int) -> None:
789
+ if n not in phases_run_seen:
790
+ phases_run_seen.add(n)
791
+ outcome.phases_run.append(n)
792
+
793
+ def _record_skipped(n: int) -> None:
794
+ if n not in phases_skipped_seen:
795
+ phases_skipped_seen.add(n)
796
+ outcome.phases_skipped.append(n)
797
+
798
+ phase = 1
799
+ while phase <= 6:
800
+ if phase == 1:
801
+ out_fn("[1/6] Detecting prior state...")
802
+ state = detect_prior_state(project_root)
803
+ out_fn(f" triageScope: {state.triage_scope_summary}")
804
+ out_fn(
805
+ f" cache: {state.cache_entry_count} raw entry/entries "
806
+ f"({'empty' if state.cache_empty else 'populated'})"
807
+ )
808
+ out_fn(
809
+ f" candidates.jsonl: "
810
+ f"{'present' if state.audit_log_present else 'absent'} "
811
+ f"({'/'.join(CANDIDATES_RELPATH)})"
812
+ )
813
+ if state.wip_cap_set:
814
+ out_fn(f" wipCap: set ({state.wip_cap})")
815
+ else:
816
+ out_fn(
817
+ f" wipCap: unset (default applied -- {DEFAULT_WIP_CAP})"
818
+ )
819
+ out_fn(f" WIP (pending/+active/): {state.wip_count}")
820
+ out_fn(f" pending human decisions: {state.pending_decisions}")
821
+ backlog_nudge = pending_decisions_nudge_line(state.pending_decisions)
822
+ if backlog_nudge:
823
+ out_fn(f" {backlog_nudge}")
824
+ # #1419 Slice 6: stranded-slice (Tier-1) + stale-epic (Tier-2)
825
+ # lifecycle-hygiene nudges, alongside the Slice 5 backlog one-liner
826
+ # above. The onboard readout is verbose, so emit every nudge here;
827
+ # the budgeted default-mode surface ranks + caps them instead.
828
+ for line in lifecycle_nudge_lines(project_root):
829
+ out_fn(f" {line}")
830
+ _record_run(1)
831
+ phase = 2
832
+ continue
833
+
834
+ if phase == 2:
835
+ state = detect_prior_state(project_root)
836
+ if state.triage_scope_set and not force_re_prompt_phase_2:
837
+ out_fn(
838
+ f"[2/6] Subscription scope already set "
839
+ f"({state.triage_scope_summary}); skipping."
840
+ )
841
+ _record_skipped(2)
842
+ phase = 3
843
+ continue
844
+ force_re_prompt_phase_2 = False
845
+ sub_outcome = prompt_menu(
846
+ title="[2/6] Choose subscription scope:",
847
+ options=[
848
+ ("Small -- all open issues (recommended <200)", "small"),
849
+ (
850
+ "Mid -- curated labels (urgent/breaking/security/p0/p1) "
851
+ "+ opened-since 60d (recommended 200-2000)",
852
+ "mid",
853
+ ),
854
+ (
855
+ "Mega -- explicit-watch + referenced-by-vbrief only "
856
+ "(recommended 2000+)",
857
+ "mega",
858
+ ),
859
+ ],
860
+ default_index=1, # Mid is the canonical recommendation
861
+ input_fn=in_fn,
862
+ output_fn=out_fn,
863
+ )
864
+ if sub_outcome.discuss:
865
+ outcome.discussed_at_phase = 2
866
+ outcome.exit_code = 0
867
+ return outcome
868
+ if sub_outcome.back:
869
+ # Phase 2 is the first interactive prompt; Back here
870
+ # re-renders Phase 1 (the detection readout), which
871
+ # iterates the loop without changing flow.
872
+ out_fn(
873
+ " [back] Nothing earlier to return to; "
874
+ "re-rendering Phase 1."
875
+ )
876
+ phase = 1
877
+ continue
878
+ preset_key = str(sub_outcome.value)
879
+ rules = SUBSCRIPTION_PRESETS[preset_key]
880
+ try:
881
+ _changed, _entry = write_triage_scope(
882
+ project_root,
883
+ rules,
884
+ preset_label=preset_key,
885
+ )
886
+ except (FileNotFoundError, ValueError) as exc:
887
+ out_fn(f" ! Failed to write plan.policy.triageScope: {exc}")
888
+ outcome.exit_code = 2
889
+ return outcome
890
+ out_fn(f" Wrote plan.policy.triageScope ({preset_key})")
891
+ outcome.subscription_choice = preset_key
892
+ _record_run(2)
893
+ phase = 3
894
+ continue
895
+
896
+ if phase == 3:
897
+ # #1244: audit log presence (NOT raw cache count) is the
898
+ # canonical "bootstrap finished" signal; see run_welcome
899
+ # docstring for the full rationale.
900
+ refreshed = detect_prior_state(project_root)
901
+ audit_rel = "/".join(CANDIDATES_RELPATH)
902
+ if refreshed.audit_log_present:
903
+ out_fn(
904
+ f"[3/6] Bootstrap audit log already present "
905
+ f"({audit_rel}, {refreshed.cache_entry_count} raw cache "
906
+ f"entry/entries); skipping `{_display_task(['triage:bootstrap'])}`."
907
+ )
908
+ outcome.bootstrap_action = (
909
+ BOOTSTRAP_ACTION_SKIPPED_ALREADY_BOOTSTRAPPED
910
+ )
911
+ _record_skipped(3)
912
+ elif skip_bootstrap:
913
+ out_fn(
914
+ f"[3/6] `{_display_task(['triage:bootstrap'])}` "
915
+ "explicitly declined via --skip-bootstrap."
916
+ )
917
+ out_fn(
918
+ f" ! {audit_rel} remains absent; downstream verbs "
919
+ f"(`{_display_task(['triage:queue'])}`, "
920
+ f"`{_display_task(['verify:cache-fresh'])}`) "
921
+ "will refuse to run."
922
+ )
923
+ out_fn(
924
+ f" ! Run `{_display_task(['triage:bootstrap'])}` separately when "
925
+ "ready to populate the cache."
926
+ )
927
+ append_audit_entry(
928
+ project_root,
929
+ (
930
+ f"actor={WELCOME_AUDIT_TAG} "
931
+ "action=bootstrap-declined "
932
+ "reason=explicit-skip-flag "
933
+ f"audit_log={audit_rel} "
934
+ "audit_log_present=false"
935
+ ),
936
+ )
937
+ outcome.bootstrap_action = BOOTSTRAP_ACTION_SKIPPED_DECLINED
938
+ _record_skipped(3)
939
+ elif not run_subprocess:
940
+ # Test-mode -- loudly surface the cache gap so dispatchers
941
+ # don't mistake dry-mode for a populated cache (#1244).
942
+ out_fn(
943
+ f"[3/6] `{_display_task(['triage:bootstrap'])}` suppressed by "
944
+ "--no-subprocess (test-mode)."
945
+ )
946
+ out_fn(
947
+ f" ! {audit_rel} remains absent; downstream verbs "
948
+ f"(`{_display_task(['triage:queue'])}`, "
949
+ f"`{_display_task(['verify:cache-fresh'])}`) "
950
+ "will refuse to run until bootstrap is invoked."
951
+ )
952
+ outcome.bootstrap_action = BOOTSTRAP_ACTION_SKIPPED_DRY_MODE
953
+ _record_skipped(3)
954
+ else:
955
+ # Audit log absent, no decline, subprocess enabled.
956
+ # Bootstrap is idempotent so re-running over a
957
+ # partially-populated `.deft-cache/` is safe.
958
+ out_fn(f"[3/6] Running `{_display_task(['triage:bootstrap'])}`...")
959
+ rc = _run_welcome_task(["triage:bootstrap"])
960
+ if rc != 0:
961
+ out_fn(
962
+ f" ! `{_display_task(['triage:bootstrap'])}` exited {rc}; "
963
+ "see stderr above. Setting outcome.exit_code=2 "
964
+ "so the dispatcher learns the ritual hit a "
965
+ "downstream failure (re-run welcome after "
966
+ "fixing bootstrap to resume)."
967
+ )
968
+ outcome.exit_code = 2
969
+ outcome.bootstrap_action = BOOTSTRAP_ACTION_RAN
970
+ _record_run(3)
971
+ phase = 4
972
+ continue
973
+
974
+ if phase == 4:
975
+ state_p4 = detect_prior_state(project_root)
976
+ if state_p4.wip_cap_set and not force_re_prompt_phase_4:
977
+ out_fn(
978
+ f"[4/6] wipCap already set ({state_p4.wip_cap}); skipping."
979
+ )
980
+ _record_skipped(4)
981
+ phase = 5
982
+ continue
983
+ force_re_prompt_phase_4 = False
984
+ cap_outcome = prompt_menu(
985
+ title="[4/6] Choose wipCap:",
986
+ options=[
987
+ ("8 (small team)", "8"),
988
+ (
989
+ f"{DEFAULT_WIP_CAP} (default per umbrella Current Shape v3)",
990
+ str(DEFAULT_WIP_CAP),
991
+ ),
992
+ ("15 (large team)", "15"),
993
+ ("custom", "custom"),
994
+ ],
995
+ default_index=1,
996
+ input_fn=in_fn,
997
+ output_fn=out_fn,
998
+ )
999
+ if cap_outcome.discuss:
1000
+ outcome.discussed_at_phase = 4
1001
+ return outcome
1002
+ if cap_outcome.back:
1003
+ # Rewind to the prior interactive prompt (Phase 2). Force
1004
+ # re-prompt even if subscription scope is already set so
1005
+ # the operator can change their previous answer.
1006
+ out_fn(" [back] Rewinding to Phase 2.")
1007
+ force_re_prompt_phase_2 = True
1008
+ phase = 2
1009
+ continue
1010
+ if cap_outcome.value == "custom":
1011
+ custom = prompt_int(
1012
+ title=" Enter custom wipCap",
1013
+ default=DEFAULT_WIP_CAP,
1014
+ input_fn=in_fn,
1015
+ output_fn=out_fn,
1016
+ )
1017
+ if custom is None:
1018
+ # prompt_int returns None on either Discuss or Back; both
1019
+ # exit the ritual at this layer (the wipCap menu is
1020
+ # already the rewind target so deeper rewind is a no-op).
1021
+ outcome.discussed_at_phase = 4
1022
+ return outcome
1023
+ cap_choice = custom
1024
+ else:
1025
+ cap_choice = int(str(cap_outcome.value))
1026
+ try:
1027
+ _changed, _entry = write_wip_cap(project_root, cap_choice)
1028
+ except (FileNotFoundError, ValueError) as exc:
1029
+ out_fn(f" ! Failed to write plan.policy.wipCap: {exc}")
1030
+ outcome.exit_code = 2
1031
+ return outcome
1032
+ if "action=cleared-to-default" in _entry:
1033
+ out_fn(
1034
+ " Cleared plan.policy.wipCap override "
1035
+ f"(inheriting framework default {cap_choice})"
1036
+ )
1037
+ elif _entry:
1038
+ out_fn(f" Wrote plan.policy.wipCap = {cap_choice}")
1039
+ else:
1040
+ out_fn(
1041
+ f" plan.policy.wipCap = {cap_choice} "
1042
+ "(framework default; field not materialized)"
1043
+ )
1044
+ outcome.wip_cap_choice = cap_choice
1045
+ _record_run(4)
1046
+ phase = 5
1047
+ continue
1048
+
1049
+ if phase == 5:
1050
+ state_p5 = detect_prior_state(project_root)
1051
+ cap = state_p5.wip_cap
1052
+ if state_p5.wip_count <= cap:
1053
+ out_fn(
1054
+ f"[5/6] WIP ({state_p5.wip_count}) within cap ({cap}); "
1055
+ "no relief needed."
1056
+ )
1057
+ _record_skipped(5)
1058
+ phase = 6
1059
+ continue
1060
+ out_fn(
1061
+ f"[5/6] WIP ({state_p5.wip_count}) exceeds cap ({cap}); "
1062
+ "previewing relief."
1063
+ )
1064
+ preview = preview_wip_relief(project_root)
1065
+ outcome.relief_offered = True
1066
+ cmd_str = _display_task(
1067
+ [
1068
+ "scope:demote",
1069
+ "--",
1070
+ "--batch",
1071
+ "--older-than-days",
1072
+ str(preview.older_than_days),
1073
+ ]
1074
+ )
1075
+ out_fn(" Planned invocation (dry-run preview):")
1076
+ out_fn(f" {cmd_str}")
1077
+ out_fn(
1078
+ f" Eligible (>= {preview.older_than_days}d in pending/): "
1079
+ f"{preview.eligible_count} file(s); "
1080
+ f"not eligible: {preview.skipped_count}"
1081
+ )
1082
+ for name in preview.eligible_files[:10]:
1083
+ out_fn(f" - {name}")
1084
+ if len(preview.eligible_files) > 10:
1085
+ out_fn(f" ... and {len(preview.eligible_files) - 10} more")
1086
+ confirm = prompt_yes_no(
1087
+ title=" Apply this relief now?",
1088
+ default_yes=False,
1089
+ input_fn=in_fn,
1090
+ output_fn=out_fn,
1091
+ )
1092
+ if confirm and preview.eligible_count > 0:
1093
+ outcome.relief_confirmed = True
1094
+ if run_subprocess:
1095
+ rc = _run_welcome_task(
1096
+ [
1097
+ "scope:demote",
1098
+ "--",
1099
+ "--batch",
1100
+ "--older-than-days",
1101
+ str(preview.older_than_days),
1102
+ ],
1103
+ )
1104
+ if rc != 0:
1105
+ out_fn(
1106
+ f" ! `{_display_task(['scope:demote'])}` exited {rc}. Setting "
1107
+ "outcome.exit_code=2 so the dispatcher learns "
1108
+ "the relief hop hit a downstream failure."
1109
+ )
1110
+ outcome.exit_code = 2
1111
+ else:
1112
+ out_fn(
1113
+ f" [dry-mode] {_display_task(['scope:demote'])} "
1114
+ "subprocess suppressed by caller."
1115
+ )
1116
+ else:
1117
+ out_fn(
1118
+ " Relief declined; WIP cap remains over by "
1119
+ f"{state_p5.wip_count - cap}."
1120
+ )
1121
+ _record_run(5)
1122
+ phase = 6
1123
+ continue
1124
+
1125
+ if phase == 6:
1126
+ out_fn("[6/6] Final state:")
1127
+ if run_subprocess:
1128
+ _run_welcome_task(["triage:summary"])
1129
+ # TODO(#1148 / N8): follow-up to add `_run_task(["policy:show",
1130
+ # "--", "--changed-only"])` here once N3's PR has shipped --
1131
+ # the inspector landed via N8 after N3 merged.
1132
+ else:
1133
+ out_fn(
1134
+ f" [dry-mode] {_display_task(['triage:summary'])} "
1135
+ "subprocess suppressed by caller."
1136
+ )
1137
+ out_fn(
1138
+ f" Next: {TRIAGE_SKILL_PATH} "
1139
+ "(read this skill to continue triage)"
1140
+ )
1141
+ _record_run(6)
1142
+ phase = 7 # exit loop
1143
+ continue
1144
+
1145
+ # Defensive: unreachable under normal flow; guards against a future
1146
+ # edit that introduces an unhandled phase value.
1147
+ raise RuntimeError(f"run_welcome: unexpected phase value {phase!r}")
1148
+
1149
+ return outcome
1150
+
1151
+
1152
+ # ---------------------------------------------------------------------------
1153
+ # CLI entry point (argparse shim lives in _triage_welcome_cli.py).
1154
+ # The default-mode (non-onboard) helpers (#1309) live in the sibling
1155
+ # module so this file stays under the 1000-line MUST cap from
1156
+ # ``coding/coding.md`` -- they are re-imported above for backward
1157
+ # compatibility.
1158
+ # ---------------------------------------------------------------------------
1159
+
1160
+
1161
+ def main(argv: list[str] | None = None) -> int:
1162
+ """CLI entry point. Delegates to :mod:`_triage_welcome_cli`."""
1163
+ import sys as _sys
1164
+
1165
+ # N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
1166
+ from triage_help import intercept_help
1167
+
1168
+ rc = intercept_help("triage_welcome", argv)
1169
+ if rc is not None:
1170
+ return rc
1171
+
1172
+ from _triage_welcome_cli import run_cli # local import: 1000-line cap
1173
+
1174
+ return run_cli(argv, _sys.modules[__name__])
1175
+
1176
+
1177
+ if __name__ == "__main__":
1178
+ sys.exit(main())