@deftai/directive-content 0.55.2 → 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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +1 -1
|
@@ -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())
|